This commit is contained in:
2025-12-18 16:51:22 +08:00
parent e3b56781e1
commit 499f3a95fd
11 changed files with 474 additions and 67 deletions

View File

@@ -6,6 +6,10 @@ NODE_ENV=development
# Database connection string # Database connection string
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/friendolls_dev?schema=public" DATABASE_URL="postgresql://postgres:postgres@localhost:5432/friendolls_dev?schema=public"
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
# JWT Configuration # JWT Configuration
# The expected issuer of the JWT token (usually {KEYCLOAK_AUTH_SERVER_URL}/realms/{KEYCLOAK_REALM}) # The expected issuer of the JWT token (usually {KEYCLOAK_AUTH_SERVER_URL}/realms/{KEYCLOAK_REALM})
JWT_ISSUER=https://your-keycloak-instance.com/auth/realms/your-realm-name JWT_ISSUER=https://your-keycloak-instance.com/auth/realms/your-realm-name

View File

@@ -29,7 +29,7 @@
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.1.9",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
@@ -39,9 +39,11 @@
"@nestjs/websockets": "^11.1.9", "@nestjs/websockets": "^11.1.9",
"@prisma/adapter-pg": "^7.0.0", "@prisma/adapter-pg": "^7.0.0",
"@prisma/client": "^7.0.0", "@prisma/client": "^7.0.0",
"@socket.io/redis-adapter": "^8.3.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0", "jwks-rsa": "^3.2.0",
"passport": "^0.7.0", "passport": "^0.7.0",
@@ -58,6 +60,7 @@
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/ioredis": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",

105
pnpm-lock.yaml generated
View File

@@ -15,7 +15,7 @@ importers:
specifier: ^4.0.2 specifier: ^4.0.2
version: 4.0.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) version: 4.0.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
'@nestjs/core': '@nestjs/core':
specifier: ^11.0.1 specifier: ^11.1.9
version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/event-emitter': '@nestjs/event-emitter':
specifier: ^3.0.1 specifier: ^3.0.1
@@ -44,6 +44,9 @@ importers:
'@prisma/client': '@prisma/client':
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0(prisma@7.0.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3) version: 7.0.0(prisma@7.0.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3)
'@socket.io/redis-adapter':
specifier: ^8.3.0
version: 8.3.0(socket.io-adapter@2.5.5)
class-transformer: class-transformer:
specifier: ^0.5.1 specifier: ^0.5.1
version: 0.5.1 version: 0.5.1
@@ -53,6 +56,9 @@ importers:
dotenv: dotenv:
specifier: ^17.2.3 specifier: ^17.2.3
version: 17.2.3 version: 17.2.3
ioredis:
specifier: ^5.8.2
version: 5.8.2
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.2 specifier: ^9.0.2
version: 9.0.2 version: 9.0.2
@@ -96,6 +102,9 @@ importers:
'@types/express': '@types/express':
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.5 version: 5.0.5
'@types/ioredis':
specifier: ^5.0.0
version: 5.0.0
'@types/jest': '@types/jest':
specifier: ^30.0.0 specifier: ^30.0.0
version: 30.0.0 version: 30.0.0
@@ -607,6 +616,9 @@ packages:
'@types/node': '@types/node':
optional: true optional: true
'@ioredis/commands@1.4.0':
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
'@isaacs/balanced-match@4.0.1': '@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -992,6 +1004,12 @@ packages:
'@socket.io/component-emitter@3.1.2': '@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
'@socket.io/redis-adapter@8.3.0':
resolution: {integrity: sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==}
engines: {node: '>=10.0.0'}
peerDependencies:
socket.io-adapter: ^2.5.4
'@standard-schema/spec@1.0.0': '@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
@@ -1065,6 +1083,10 @@ packages:
'@types/http-errors@2.0.5': '@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/ioredis@5.0.0':
resolution: {integrity: sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g==}
deprecated: This is a stub types definition. ioredis provides its own type definitions, so you do not need this installed.
'@types/istanbul-lib-coverage@2.0.6': '@types/istanbul-lib-coverage@2.0.6':
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
@@ -1650,6 +1672,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
co@4.6.0: co@4.6.0:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@@ -2288,6 +2314,10 @@ packages:
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ioredis@5.8.2:
resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==}
engines: {node: '>=12.22.0'}
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -2605,9 +2635,15 @@ packages:
lodash.clonedeep@4.5.0: lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
lodash.includes@4.3.0: lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash.isboolean@3.0.3: lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
@@ -2819,6 +2855,9 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
notepack.io@3.0.1:
resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==}
npm-run-path@4.0.1: npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3120,6 +3159,14 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
reflect-metadata@0.2.2: reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@@ -3296,6 +3343,9 @@ packages:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
statuses@2.0.2: statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -3534,6 +3584,10 @@ packages:
engines: {node: '>=0.8.0'} engines: {node: '>=0.8.0'}
hasBin: true hasBin: true
uid2@1.0.0:
resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==}
engines: {node: '>= 4.0.0'}
uid@2.0.2: uid@2.0.2:
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4199,6 +4253,8 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/node': 22.19.1 '@types/node': 22.19.1
'@ioredis/commands@1.4.0': {}
'@isaacs/balanced-match@4.0.1': {} '@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0': '@isaacs/brace-expansion@5.0.0':
@@ -4735,6 +4791,15 @@ snapshots:
'@socket.io/component-emitter@3.1.2': {} '@socket.io/component-emitter@3.1.2': {}
'@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.5)':
dependencies:
debug: 4.3.7
notepack.io: 3.0.1
socket.io-adapter: 2.5.5
uid2: 1.0.0
transitivePeerDependencies:
- supports-color
'@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.0.0': {}
'@tokenizer/inflate@0.3.1': '@tokenizer/inflate@0.3.1':
@@ -4837,6 +4902,12 @@ snapshots:
'@types/http-errors@2.0.5': {} '@types/http-errors@2.0.5': {}
'@types/ioredis@5.0.0':
dependencies:
ioredis: 5.8.2
transitivePeerDependencies:
- supports-color
'@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-coverage@2.0.6': {}
'@types/istanbul-lib-report@3.0.3': '@types/istanbul-lib-report@3.0.3':
@@ -5485,6 +5556,8 @@ snapshots:
clone@1.0.4: {} clone@1.0.4: {}
cluster-key-slot@1.1.2: {}
co@4.6.0: {} co@4.6.0: {}
collect-v8-coverage@1.0.3: {} collect-v8-coverage@1.0.3: {}
@@ -6142,6 +6215,20 @@ snapshots:
inherits@2.0.4: {} inherits@2.0.4: {}
ioredis@5.8.2:
dependencies:
'@ioredis/commands': 1.4.0
cluster-key-slot: 1.1.2
debug: 4.4.3
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
is-arrayish@0.2.1: {} is-arrayish@0.2.1: {}
@@ -6636,8 +6723,12 @@ snapshots:
lodash.clonedeep@4.5.0: {} lodash.clonedeep@4.5.0: {}
lodash.defaults@4.2.0: {}
lodash.includes@4.3.0: {} lodash.includes@4.3.0: {}
lodash.isarguments@3.1.0: {}
lodash.isboolean@3.0.3: {} lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {} lodash.isinteger@4.0.4: {}
@@ -6811,6 +6902,8 @@ snapshots:
normalize-path@3.0.0: {} normalize-path@3.0.0: {}
notepack.io@3.0.1: {}
npm-run-path@4.0.1: npm-run-path@4.0.1:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -7095,6 +7188,12 @@ snapshots:
readdirp@4.1.2: {} readdirp@4.1.2: {}
redis-errors@1.2.0: {}
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
reflect-metadata@0.2.2: {} reflect-metadata@0.2.2: {}
regexp-to-ast@0.5.0: {} regexp-to-ast@0.5.0: {}
@@ -7298,6 +7397,8 @@ snapshots:
dependencies: dependencies:
escape-string-regexp: 2.0.0 escape-string-regexp: 2.0.0
standard-as-callback@2.1.0: {}
statuses@2.0.2: {} statuses@2.0.2: {}
std-env@3.9.0: {} std-env@3.9.0: {}
@@ -7530,6 +7631,8 @@ snapshots:
uglify-js@3.19.3: uglify-js@3.19.3:
optional: true optional: true
uid2@1.0.0: {}
uid@2.0.2: uid@2.0.2:
dependencies: dependencies:
'@lukeed/csprng': 1.1.0 '@lukeed/csprng': 1.1.0

View File

@@ -7,6 +7,7 @@ import { AppService } from './app.service';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
import { RedisModule } from './database/redis.module';
import { WsModule } from './ws/ws.module'; import { WsModule } from './ws/ws.module';
import { FriendsModule } from './friends/friends.module'; import { FriendsModule } from './friends/friends.module';
@@ -63,6 +64,7 @@ function validateEnvironment(config: Record<string, any>): Record<string, any> {
}), }),
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
DatabaseModule, DatabaseModule,
RedisModule,
UsersModule, UsersModule,
AuthModule, AuthModule,
WsModule, WsModule,

View File

@@ -0,0 +1,52 @@
import { Module, Global, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
export const REDIS_CLIENT = 'REDIS_CLIENT';
@Global()
@Module({
providers: [
{
provide: REDIS_CLIENT,
useFactory: (configService: ConfigService) => {
const logger = new Logger('RedisModule');
const host = configService.get<string>('REDIS_HOST');
const port = configService.get<number>('REDIS_PORT');
const password = configService.get<string>('REDIS_PASSWORD');
// Fallback or "disabled" mode if no host is provided
if (!host) {
logger.warn(
'REDIS_HOST not defined. Redis features will be disabled or fall back to local memory.',
);
return null;
}
const client = new Redis({
host,
port: port || 6379,
password: password,
// Retry strategy: keep trying to reconnect
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
});
client.on('error', (err) => {
logger.error('Redis connection error', err);
});
client.on('connect', () => {
logger.log(`Connected to Redis at ${host}:${port || 6379}`);
});
return client;
},
inject: [ConfigService],
},
],
exports: [REDIS_CLIENT],
})
export class RedisModule {}

View File

@@ -1,12 +1,20 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common'; import { ValidationPipe, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { RedisIoAdapter } from './ws/redis-io.adapter';
async function bootstrap() { async function bootstrap() {
const logger = new Logger('Bootstrap'); const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// Configure Redis Adapter for horizontal scaling (if enabled)
const redisIoAdapter = new RedisIoAdapter(app, configService);
await redisIoAdapter.connectToRedis();
app.useWebSocketAdapter(redisIoAdapter);
// Enable global exception filter for consistent error responses // Enable global exception filter for consistent error responses
app.useGlobalFilters(new AllExceptionsFilter()); app.useGlobalFilters(new AllExceptionsFilter());

View File

@@ -0,0 +1,81 @@
import { IoAdapter } from '@nestjs/platform-socket.io';
import { ServerOptions } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { ConfigService } from '@nestjs/config';
import { INestApplicationContext, Logger } from '@nestjs/common';
export class RedisIoAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createAdapter>;
private readonly logger = new Logger(RedisIoAdapter.name);
constructor(
private app: INestApplicationContext,
private configService: ConfigService,
) {
super(app);
}
async connectToRedis(): Promise<void> {
const host = this.configService.get<string>('REDIS_HOST');
const port = this.configService.get<number>('REDIS_PORT');
const password = this.configService.get<string>('REDIS_PASSWORD');
// Only set up Redis adapter if host is configured
if (!host) {
this.logger.log('Redis adapter disabled (REDIS_HOST not set)');
return;
}
this.logger.log(`Connecting Redis adapter to ${host}:${port || 6379}`);
try {
const pubClient = new Redis({
host,
port: port || 6379,
password: password,
retryStrategy(times) {
// Retry connecting but don't crash if Redis is temporarily down during startup
return Math.min(times * 50, 2000);
},
});
const subClient = pubClient.duplicate();
// Wait for connection to ensure it's valid
await new Promise<void>((resolve, reject) => {
pubClient.once('connect', () => {
this.logger.log('Redis Pub client connected');
resolve();
});
pubClient.once('error', (err) => {
this.logger.error('Redis Pub client error', err);
reject(err);
});
});
// Handle subsequent errors gracefully
pubClient.on('error', (err) => {
this.logger.error('Redis Pub client error', err);
});
subClient.on('error', (err) => {
this.logger.error('Redis Sub client error', err);
});
this.adapterConstructor = createAdapter(pubClient, subClient);
this.logger.log('Redis adapter initialized successfully');
} catch (error) {
this.logger.error('Failed to initialize Redis adapter', error);
// We don't throw here to allow the app to start without Redis if connection fails,
// though functionality will be degraded if multiple instances are running.
}
}
createIOServer(port: number, options?: ServerOptions): any {
const server = super.createIOServer(port, options);
if (this.adapterConstructor) {
server.adapter(this.adapterConstructor);
}
return server;
}
}

View File

@@ -5,12 +5,17 @@ import { AuthenticatedSocket } from '../../types/socket';
import { AuthService } from '../../auth/auth.service'; import { AuthService } from '../../auth/auth.service';
import { JwtVerificationService } from '../../auth/services/jwt-verification.service'; import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
import { PrismaService } from '../../database/prisma.service'; import { PrismaService } from '../../database/prisma.service';
import { UserSocketService } from './user-socket.service';
interface MockSocket extends Partial<AuthenticatedSocket> { interface MockSocket extends Partial<AuthenticatedSocket> {
id: string; id: string;
data: { data: {
user?: { user?: {
keycloakSub: string; keycloakSub: string;
email?: string;
name?: string;
preferred_username?: string;
picture?: string;
}; };
userId?: string; userId?: string;
friends?: Set<string>; friends?: Set<string>;
@@ -31,6 +36,7 @@ describe('StateGateway', () => {
let mockAuthService: Partial<AuthService>; let mockAuthService: Partial<AuthService>;
let mockJwtVerificationService: Partial<JwtVerificationService>; let mockJwtVerificationService: Partial<JwtVerificationService>;
let mockPrismaService: Partial<PrismaService>; let mockPrismaService: Partial<PrismaService>;
let mockUserSocketService: Partial<UserSocketService>;
beforeEach(async () => { beforeEach(async () => {
mockServer = { mockServer = {
@@ -66,6 +72,14 @@ describe('StateGateway', () => {
}, },
}; };
mockUserSocketService = {
setSocket: jest.fn().mockResolvedValue(undefined),
removeSocket: jest.fn().mockResolvedValue(undefined),
getSocket: jest.fn().mockResolvedValue(null),
isUserOnline: jest.fn().mockResolvedValue(false),
getFriendsSockets: jest.fn().mockResolvedValue([]),
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
StateGateway, StateGateway,
@@ -75,6 +89,7 @@ describe('StateGateway', () => {
useValue: mockJwtVerificationService, useValue: mockJwtVerificationService,
}, },
{ provide: PrismaService, useValue: mockPrismaService }, { provide: PrismaService, useValue: mockPrismaService },
{ provide: UserSocketService, useValue: mockUserSocketService },
], ],
}).compile(); }).compile();
@@ -130,6 +145,10 @@ describe('StateGateway', () => {
keycloakSub: 'test-sub', keycloakSub: 'test-sub',
}), }),
); );
expect(mockUserSocketService.setSocket).toHaveBeenCalledWith(
'user-id',
'client1',
);
expect(mockLoggerLog).toHaveBeenCalledWith( expect(mockLoggerLog).toHaveBeenCalledWith(
`Client id: ${mockClient.id} connected (user: test-sub)`, `Client id: ${mockClient.id} connected (user: test-sub)`,
); );
@@ -165,35 +184,57 @@ describe('StateGateway', () => {
}); });
describe('handleDisconnect', () => { describe('handleDisconnect', () => {
it('should log client disconnection', () => { it('should log client disconnection', async () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { user: { keycloakSub: 'test-sub' } }, data: { user: { keycloakSub: 'test-sub' } },
}; };
gateway.handleDisconnect(mockClient as unknown as AuthenticatedSocket); await gateway.handleDisconnect(mockClient as unknown as AuthenticatedSocket);
expect(mockLoggerLog).toHaveBeenCalledWith( expect(mockLoggerLog).toHaveBeenCalledWith(
`Client id: ${mockClient.id} disconnected (user: test-sub)`, `Client id: ${mockClient.id} disconnected (user: test-sub)`,
); );
}); });
it('should handle disconnection when no user data', () => { it('should handle disconnection when no user data', async () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: {}, data: {},
}; };
gateway.handleDisconnect(mockClient as unknown as AuthenticatedSocket); await gateway.handleDisconnect(mockClient as unknown as AuthenticatedSocket);
expect(mockLoggerLog).toHaveBeenCalledWith( expect(mockLoggerLog).toHaveBeenCalledWith(
`Client id: ${mockClient.id} disconnected (user: unknown)`, `Client id: ${mockClient.id} disconnected (user: unknown)`,
); );
}); });
it('should remove socket if it matches', async () => {
const mockClient: MockSocket = {
id: 'client1',
data: {
user: { keycloakSub: 'test-sub' },
userId: 'user-id',
friends: new Set(['friend-1']),
},
};
(mockUserSocketService.getSocket as jest.Mock).mockResolvedValue('client1');
(mockUserSocketService.getFriendsSockets as jest.Mock).mockResolvedValue([
{ userId: 'friend-1', socketId: 'friend-socket-id' }
]);
await gateway.handleDisconnect(mockClient as unknown as AuthenticatedSocket);
expect(mockUserSocketService.getSocket).toHaveBeenCalledWith('user-id');
expect(mockUserSocketService.removeSocket).toHaveBeenCalledWith('user-id');
expect(mockServer.to).toHaveBeenCalledWith('friend-socket-id');
});
}); });
describe('handleCursorReportPosition', () => { describe('handleCursorReportPosition', () => {
it('should emit cursor position to connected friends', () => { it('should emit cursor position to connected friends', async () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
@@ -203,13 +244,14 @@ describe('StateGateway', () => {
}, },
}; };
// Setup the userSocketMap to simulate a connected friend // Mock getFriendsSockets to return the friend's socket
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access (mockUserSocketService.getFriendsSockets as jest.Mock).mockResolvedValue([
(gateway as any).userSocketMap.set('friend-1', 'friend-socket-id'); { userId: 'friend-1', socketId: 'friend-socket-id' },
]);
const data: CursorPositionDto = { x: 100, y: 200 }; const data: CursorPositionDto = { x: 100, y: 200 };
gateway.handleCursorReportPosition( await gateway.handleCursorReportPosition(
mockClient as unknown as AuthenticatedSocket, mockClient as unknown as AuthenticatedSocket,
data, data,
); );
@@ -224,7 +266,7 @@ describe('StateGateway', () => {
}); });
}); });
it('should not emit when no friends are online', () => { it('should not emit when no friends are online', async () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
@@ -234,10 +276,12 @@ describe('StateGateway', () => {
}, },
}; };
// Don't set up userSocketMap - friend is not online // Mock getFriendsSockets to return empty array
(mockUserSocketService.getFriendsSockets as jest.Mock).mockResolvedValue([]);
const data: CursorPositionDto = { x: 100, y: 200 }; const data: CursorPositionDto = { x: 100, y: 200 };
gateway.handleCursorReportPosition( await gateway.handleCursorReportPosition(
mockClient as unknown as AuthenticatedSocket, mockClient as unknown as AuthenticatedSocket,
data, data,
); );
@@ -246,7 +290,7 @@ describe('StateGateway', () => {
expect(mockServer.to).not.toHaveBeenCalled(); expect(mockServer.to).not.toHaveBeenCalled();
}); });
it('should log warning when userId is missing', () => { it('should log warning when userId is missing', async () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
@@ -258,7 +302,7 @@ describe('StateGateway', () => {
const data: CursorPositionDto = { x: 100, y: 200 }; const data: CursorPositionDto = { x: 100, y: 200 };
gateway.handleCursorReportPosition( await gateway.handleCursorReportPosition(
mockClient as unknown as AuthenticatedSocket, mockClient as unknown as AuthenticatedSocket,
data, data,
); );
@@ -271,19 +315,19 @@ describe('StateGateway', () => {
expect(mockServer.to).not.toHaveBeenCalled(); expect(mockServer.to).not.toHaveBeenCalled();
}); });
it('should throw exception when client is not authenticated', () => { it('should throw exception when client is not authenticated', async () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: {}, data: {},
}; };
const data: CursorPositionDto = { x: 100, y: 200 }; const data: CursorPositionDto = { x: 100, y: 200 };
expect(() => { await expect(
gateway.handleCursorReportPosition( gateway.handleCursorReportPosition(
mockClient as unknown as AuthenticatedSocket, mockClient as unknown as AuthenticatedSocket,
data, data,
); ),
}).toThrow('Unauthorized'); ).rejects.toThrow('Unauthorized');
}); });
}); });
}); });

View File

@@ -16,6 +16,7 @@ import { AuthService } from '../../auth/auth.service';
import { JwtVerificationService } from '../../auth/services/jwt-verification.service'; import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
import { CursorPositionDto } from '../dto/cursor-position.dto'; import { CursorPositionDto } from '../dto/cursor-position.dto';
import { PrismaService } from '../../database/prisma.service'; import { PrismaService } from '../../database/prisma.service';
import { UserSocketService } from './user-socket.service';
import { FriendEvents } from '../../friends/events/friend.events'; import { FriendEvents } from '../../friends/events/friend.events';
import type { import type {
@@ -45,7 +46,6 @@ export class StateGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{ {
private readonly logger = new Logger(StateGateway.name); private readonly logger = new Logger(StateGateway.name);
private userSocketMap: Map<string, string> = new Map();
private lastBroadcastMap: Map<string, number> = new Map(); private lastBroadcastMap: Map<string, number> = new Map();
@WebSocketServer() io: Server; @WebSocketServer() io: Server;
@@ -54,6 +54,7 @@ export class StateGateway
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly jwtVerificationService: JwtVerificationService, private readonly jwtVerificationService: JwtVerificationService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly userSocketService: UserSocketService,
) {} ) {}
afterInit() { afterInit() {
@@ -94,7 +95,7 @@ export class StateGateway
this.logger.log(`WebSocket authenticated: ${payload.sub}`); this.logger.log(`WebSocket authenticated: ${payload.sub}`);
const user = await this.authService.syncUserFromToken(client.data.user); const user = await this.authService.syncUserFromToken(client.data.user);
this.userSocketMap.set(user.id, client.id); await this.userSocketService.setSocket(user.id, client.id);
client.data.userId = user.id; client.data.userId = user.id;
// Initialize friends cache using Prisma directly // Initialize friends cache using Prisma directly
@@ -117,7 +118,7 @@ export class StateGateway
} }
} }
handleDisconnect(client: AuthenticatedSocket) { async handleDisconnect(client: AuthenticatedSocket) {
const user = client.data.user; const user = client.data.user;
if (user) { if (user) {
@@ -125,34 +126,28 @@ export class StateGateway
if (userId) { if (userId) {
// Check if this socket is still the active one for the user // Check if this socket is still the active one for the user
const currentSocketId = this.userSocketMap.get(userId); const currentSocketId = await this.userSocketService.getSocket(userId);
if (currentSocketId === client.id) { if (currentSocketId === client.id) {
this.userSocketMap.delete(userId); await this.userSocketService.removeSocket(userId);
this.lastBroadcastMap.delete(userId); this.lastBroadcastMap.delete(userId);
// Notify friends that this user has disconnected // Notify friends that this user has disconnected
const friends = client.data.friends; const friends = client.data.friends;
if (friends) { if (friends) {
for (const friendId of friends) { const friendIds = Array.from(friends);
const friendSocketId = this.userSocketMap.get(friendId); const friendSockets = await this.userSocketService.getFriendsSockets(friendIds);
if (friendSocketId) {
this.io.to(friendSocketId).emit(WS_EVENT.FRIEND_DISCONNECTED, { for (const { socketId } of friendSockets) {
userId: userId, this.io.to(socketId).emit(WS_EVENT.FRIEND_DISCONNECTED, {
}); userId: userId,
} });
} }
} }
} }
} else {
// Fallback for cases where client.data.userId might not be set
for (const [uid, socketId] of this.userSocketMap.entries()) {
if (socketId === client.id) {
this.userSocketMap.delete(uid);
this.lastBroadcastMap.delete(uid);
break;
}
}
} }
// Note: We can't iterate over Redis keys easily to find socketId match without userId
// The previous fallback loop over map entries is not efficient with Redis.
// We rely on client.data.userId being set correctly during connection.
} }
this.logger.log( this.logger.log(
@@ -160,12 +155,12 @@ export class StateGateway
); );
} }
isUserOnline(userId: string): boolean { async isUserOnline(userId: string): Promise<boolean> {
return this.userSocketMap.has(userId); return this.userSocketService.isUserOnline(userId);
} }
@SubscribeMessage(WS_EVENT.CURSOR_REPORT_POSITION) @SubscribeMessage(WS_EVENT.CURSOR_REPORT_POSITION)
handleCursorReportPosition( async handleCursorReportPosition(
client: AuthenticatedSocket, client: AuthenticatedSocket,
data: CursorPositionDto, data: CursorPositionDto,
) { ) {
@@ -192,25 +187,25 @@ export class StateGateway
// Broadcast to online friends // Broadcast to online friends
const friends = client.data.friends; const friends = client.data.friends;
if (friends) { if (friends) {
for (const friendId of friends) { const friendIds = Array.from(friends);
const friendSocketId = this.userSocketMap.get(friendId); const friendSockets = await this.userSocketService.getFriendsSockets(friendIds);
if (friendSocketId) {
const payload = { for (const { socketId } of friendSockets) {
userId: currentUserId, const payload = {
position: data, userId: currentUserId,
}; position: data,
this.io };
.to(friendSocketId) this.io
.emit(WS_EVENT.FRIEND_CURSOR_POSITION, payload); .to(socketId)
} .emit(WS_EVENT.FRIEND_CURSOR_POSITION, payload);
} }
} }
} }
@OnEvent(FriendEvents.REQUEST_RECEIVED) @OnEvent(FriendEvents.REQUEST_RECEIVED)
handleFriendRequestReceived(payload: FriendRequestReceivedEvent) { async handleFriendRequestReceived(payload: FriendRequestReceivedEvent) {
const { userId, friendRequest } = payload; const { userId, friendRequest } = payload;
const socketId = this.userSocketMap.get(userId); const socketId = await this.userSocketService.getSocket(userId);
if (socketId) { if (socketId) {
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_RECEIVED, { this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_RECEIVED, {
id: friendRequest.id, id: friendRequest.id,
@@ -229,10 +224,10 @@ export class StateGateway
} }
@OnEvent(FriendEvents.REQUEST_ACCEPTED) @OnEvent(FriendEvents.REQUEST_ACCEPTED)
handleFriendRequestAccepted(payload: FriendRequestAcceptedEvent) { async handleFriendRequestAccepted(payload: FriendRequestAcceptedEvent) {
const { userId, friendRequest } = payload; const { userId, friendRequest } = payload;
const socketId = this.userSocketMap.get(userId); const socketId = await this.userSocketService.getSocket(userId);
// 1. Update cache for the user who sent the request (userId / friendRequest.senderId) // 1. Update cache for the user who sent the request (userId / friendRequest.senderId)
if (socketId) { if (socketId) {
@@ -259,7 +254,7 @@ export class StateGateway
} }
// 2. Update cache for the user who accepted the request (friendRequest.receiverId) // 2. Update cache for the user who accepted the request (friendRequest.receiverId)
const receiverSocketId = this.userSocketMap.get(friendRequest.receiverId); const receiverSocketId = await this.userSocketService.getSocket(friendRequest.receiverId);
if (receiverSocketId) { if (receiverSocketId) {
const receiverSocket = this.io.sockets.sockets.get( const receiverSocket = this.io.sockets.sockets.get(
receiverSocketId, receiverSocketId,
@@ -271,9 +266,9 @@ export class StateGateway
} }
@OnEvent(FriendEvents.REQUEST_DENIED) @OnEvent(FriendEvents.REQUEST_DENIED)
handleFriendRequestDenied(payload: FriendRequestDeniedEvent) { async handleFriendRequestDenied(payload: FriendRequestDeniedEvent) {
const { userId, friendRequest } = payload; const { userId, friendRequest } = payload;
const socketId = this.userSocketMap.get(userId); const socketId = await this.userSocketService.getSocket(userId);
if (socketId) { if (socketId) {
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_DENIED, { this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_DENIED, {
id: friendRequest.id, id: friendRequest.id,
@@ -292,10 +287,10 @@ export class StateGateway
} }
@OnEvent(FriendEvents.UNFRIENDED) @OnEvent(FriendEvents.UNFRIENDED)
handleUnfriended(payload: UnfriendedEvent) { async handleUnfriended(payload: UnfriendedEvent) {
const { userId, friendId } = payload; const { userId, friendId } = payload;
const socketId = this.userSocketMap.get(userId); const socketId = await this.userSocketService.getSocket(userId);
// 1. Update cache for the user receiving the notification (userId) // 1. Update cache for the user receiving the notification (userId)
if (socketId) { if (socketId) {
@@ -313,7 +308,7 @@ export class StateGateway
} }
// 2. Update cache for the user initiating the unfriend (friendId) // 2. Update cache for the user initiating the unfriend (friendId)
const initiatorSocketId = this.userSocketMap.get(friendId); const initiatorSocketId = await this.userSocketService.getSocket(friendId);
if (initiatorSocketId) { if (initiatorSocketId) {
const initiatorSocket = this.io.sockets.sockets.get( const initiatorSocket = this.io.sockets.sockets.get(
initiatorSocketId, initiatorSocketId,

View File

@@ -0,0 +1,113 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { REDIS_CLIENT } from '../../database/redis.module';
import Redis from 'ioredis';
@Injectable()
export class UserSocketService {
private readonly logger = new Logger(UserSocketService.name);
private localUserSocketMap: Map<string, string> = new Map();
private readonly PREFIX = 'socket:user:';
private readonly TTL = 86400; // 24 hours
constructor(
@Inject(REDIS_CLIENT) private readonly redisClient: Redis | null,
) {}
async setSocket(userId: string, socketId: string): Promise<void> {
if (this.redisClient) {
try {
await this.redisClient.set(
`${this.PREFIX}${userId}`,
socketId,
'EX',
this.TTL,
);
} catch (error) {
this.logger.error(
`Failed to set socket for user ${userId} in Redis`,
error,
);
// Fallback to local map on error? Or just log?
// Let's use local map as backup if redis is down/null
this.localUserSocketMap.set(userId, socketId);
}
} else {
this.localUserSocketMap.set(userId, socketId);
}
}
async removeSocket(userId: string): Promise<void> {
if (this.redisClient) {
try {
await this.redisClient.del(`${this.PREFIX}${userId}`);
} catch (error) {
this.logger.error(
`Failed to remove socket for user ${userId} from Redis`,
error,
);
}
}
this.localUserSocketMap.delete(userId);
}
async getSocket(userId: string): Promise<string | null> {
if (this.redisClient) {
try {
const socketId = await this.redisClient.get(`${this.PREFIX}${userId}`);
return socketId;
} catch (error) {
this.logger.error(
`Failed to get socket for user ${userId} from Redis`,
error,
);
return this.localUserSocketMap.get(userId) || null;
}
}
return this.localUserSocketMap.get(userId) || null;
}
async isUserOnline(userId: string): Promise<boolean> {
const socketId = await this.getSocket(userId);
return !!socketId;
}
async getFriendsSockets(friendIds: string[]): Promise<{ userId: string; socketId: string }[]> {
if (friendIds.length === 0) {
return [];
}
if (this.redisClient) {
try {
// Use pipeline for batch fetching
const pipeline = this.redisClient.pipeline();
friendIds.forEach((id) => pipeline.get(`${this.PREFIX}${id}`));
const results = await pipeline.exec();
const sockets: { userId: string; socketId: string }[] = [];
if (results) {
results.forEach((result, index) => {
const [err, socketId] = result;
if (!err && socketId && typeof socketId === 'string') {
sockets.push({ userId: friendIds[index], socketId });
}
});
}
return sockets;
} catch (error) {
this.logger.error('Failed to batch get friend sockets from Redis', error);
// Fallback to local implementation
}
}
// Local fallback
const sockets: { userId: string; socketId: string }[] = [];
for (const friendId of friendIds) {
const socketId = this.localUserSocketMap.get(friendId);
if (socketId) {
sockets.push({ userId: friendId, socketId });
}
}
return sockets;
}
}

View File

@@ -1,11 +1,13 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { StateGateway } from './state/state.gateway'; import { StateGateway } from './state/state.gateway';
import { UserSocketService } from './state/user-socket.service';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { FriendsModule } from '../friends/friends.module'; import { FriendsModule } from '../friends/friends.module';
import { RedisModule } from '../database/redis.module';
@Module({ @Module({
imports: [AuthModule, forwardRef(() => FriendsModule)], imports: [AuthModule, RedisModule, forwardRef(() => FriendsModule)],
providers: [StateGateway], providers: [StateGateway, UserSocketService],
exports: [StateGateway], exports: [StateGateway],
}) })
export class WsModule {} export class WsModule {}