backend: Implement participant name reservation system
- Added ParticipantNameService to manage unique participant name reservations. - Integrated name reservation in ParticipantService during token generation. - Implemented cleanup of expired name reservations in LivekitWebhookService. - Enhanced RedisService with atomic operations for name reservation. - Updated internal configuration for participant name reservation limits. - Added tests for participant name reservation and release functionality. - Updated frontend dependencies to use the latest version of openvidu-components-angular.
This commit is contained in:
parent
8203be2687
commit
a636ad485f
@ -21,8 +21,6 @@
|
|||||||
$ref: '../../components/responses/forbidden-error.yaml'
|
$ref: '../../components/responses/forbidden-error.yaml'
|
||||||
'404':
|
'404':
|
||||||
$ref: '../../components/responses/error-room-not-found.yaml'
|
$ref: '../../components/responses/error-room-not-found.yaml'
|
||||||
'409':
|
|
||||||
$ref: '../../components/responses/internal/error-participant-already-exists.yaml'
|
|
||||||
'422':
|
'422':
|
||||||
$ref: '../../components/responses/validation-error.yaml'
|
$ref: '../../components/responses/validation-error.yaml'
|
||||||
'500':
|
'500':
|
||||||
|
|||||||
102
backend/package-lock.json
generated
102
backend/package-lock.json
generated
@ -1182,22 +1182,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/core": {
|
"node_modules/@babel/core": {
|
||||||
"version": "7.28.0",
|
"version": "7.28.3",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz",
|
||||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.0",
|
"@babel/generator": "^7.28.3",
|
||||||
"@babel/helper-compilation-targets": "^7.27.2",
|
"@babel/helper-compilation-targets": "^7.27.2",
|
||||||
"@babel/helper-module-transforms": "^7.27.3",
|
"@babel/helper-module-transforms": "^7.28.3",
|
||||||
"@babel/helpers": "^7.27.6",
|
"@babel/helpers": "^7.28.3",
|
||||||
"@babel/parser": "^7.28.0",
|
"@babel/parser": "^7.28.3",
|
||||||
"@babel/template": "^7.27.2",
|
"@babel/template": "^7.27.2",
|
||||||
"@babel/traverse": "^7.28.0",
|
"@babel/traverse": "^7.28.3",
|
||||||
"@babel/types": "^7.28.0",
|
"@babel/types": "^7.28.2",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"debug": "^4.1.0",
|
"debug": "^4.1.0",
|
||||||
"gensync": "^1.0.0-beta.2",
|
"gensync": "^1.0.0-beta.2",
|
||||||
@ -1223,14 +1223,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.28.0",
|
"version": "7.28.3",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
|
||||||
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
|
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.0",
|
"@babel/parser": "^7.28.3",
|
||||||
"@babel/types": "^7.28.0",
|
"@babel/types": "^7.28.2",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"jsesc": "^3.0.2"
|
"jsesc": "^3.0.2"
|
||||||
@ -1291,15 +1291,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-transforms": {
|
"node_modules/@babel/helper-module-transforms": {
|
||||||
"version": "7.27.3",
|
"version": "7.28.3",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
|
||||||
"integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
|
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.27.1",
|
"@babel/helper-module-imports": "^7.27.1",
|
||||||
"@babel/helper-validator-identifier": "^7.27.1",
|
"@babel/helper-validator-identifier": "^7.27.1",
|
||||||
"@babel/traverse": "^7.27.3"
|
"@babel/traverse": "^7.28.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@ -1349,9 +1349,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.28.2",
|
"version": "7.28.3",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz",
|
||||||
"integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==",
|
"integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -1363,13 +1363,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.28.0",
|
"version": "7.28.3",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz",
|
||||||
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
|
"integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.28.0"
|
"@babel/types": "^7.28.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@ -1633,18 +1633,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.28.0",
|
"version": "7.28.3",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz",
|
||||||
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
|
"integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.0",
|
"@babel/generator": "^7.28.3",
|
||||||
"@babel/helper-globals": "^7.28.0",
|
"@babel/helper-globals": "^7.28.0",
|
||||||
"@babel/parser": "^7.28.0",
|
"@babel/parser": "^7.28.3",
|
||||||
"@babel/template": "^7.27.2",
|
"@babel/template": "^7.27.2",
|
||||||
"@babel/types": "^7.28.0",
|
"@babel/types": "^7.28.2",
|
||||||
"debug": "^4.3.1"
|
"debug": "^4.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -2326,9 +2326,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@inquirer/confirm": {
|
"node_modules/@inquirer/confirm": {
|
||||||
"version": "5.1.14",
|
"version": "5.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.15.tgz",
|
||||||
"integrity": "sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q==",
|
"integrity": "sha512-SwHMGa8Z47LawQN0rog0sT+6JpiL0B7eW9p1Bb7iCeKDGTI5Ez25TSc2l8kw52VV7hA4sX/C78CGkMrKXfuspA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -2548,14 +2548,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@inquirer/prompts": {
|
"node_modules/@inquirer/prompts": {
|
||||||
"version": "7.8.2",
|
"version": "7.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.3.tgz",
|
||||||
"integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==",
|
"integrity": "sha512-iHYp+JCaCRktM/ESZdpHI51yqsDgXu+dMs4semzETftOaF8u5hwlqnbIsuIR/LrWZl8Pm1/gzteK9I7MAq5HTA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inquirer/checkbox": "^4.2.1",
|
"@inquirer/checkbox": "^4.2.1",
|
||||||
"@inquirer/confirm": "^5.1.14",
|
"@inquirer/confirm": "^5.1.15",
|
||||||
"@inquirer/editor": "^4.2.17",
|
"@inquirer/editor": "^4.2.17",
|
||||||
"@inquirer/expand": "^4.0.17",
|
"@inquirer/expand": "^4.0.17",
|
||||||
"@inquirer/input": "^4.2.1",
|
"@inquirer/input": "^4.2.1",
|
||||||
@ -2739,9 +2739,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
|
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
|
||||||
"version": "6.1.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
|
||||||
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
|
"integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@ -3648,9 +3648,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/protocol": {
|
"node_modules/@livekit/protocol": {
|
||||||
"version": "1.39.3",
|
"version": "1.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.39.3.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.40.0.tgz",
|
||||||
"integrity": "sha512-hfOnbwPCeZBEvMRdRhU2sr46mjGXavQcrb3BFRfG+Gm0Z7WUSeFdy5WLstXJzEepz17Iwp/lkGwJ4ZgOOYfPuA==",
|
"integrity": "sha512-1q0TNqlSTDW9ZuQCYTLDusyO+StvAXPmECQgyuszThDjzhTwQOkA6Rv7B1wPcvpTcwieIG0vuAO4cw4+kg+xWA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^1.10.0"
|
"@bufbuild/protobuf": "^1.10.0"
|
||||||
@ -7517,9 +7517,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.200",
|
"version": "1.5.203",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.203.tgz",
|
||||||
"integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==",
|
"integrity": "sha512-uz4i0vLhfm6dLZWbz/iH88KNDV+ivj5+2SA+utpgjKaj9Q0iDLuwk6Idhe9BTxciHudyx6IvTvijhkPvFGUQ0g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@ -12366,14 +12366,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/openapi-generate-html/node_modules/inquirer": {
|
"node_modules/openapi-generate-html/node_modules/inquirer": {
|
||||||
"version": "12.9.2",
|
"version": "12.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.9.3.tgz",
|
||||||
"integrity": "sha512-XPukbomHpZc3GAajQdAcuqa5NCIFhUcLMcXXSpJLM2RW/u/5JHLxjLF206GNTJARib8XBBRqyMbaNrDzXROdoA==",
|
"integrity": "sha512-Hpw2JWdrYY8xJSmhU05Idd5FPshQ1CZErH00WO+FK6fKxkBeqj+E+yFXSlERZLKtzWeQYFCMfl8U2TK9SvVbtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inquirer/core": "^10.1.15",
|
"@inquirer/core": "^10.1.15",
|
||||||
"@inquirer/prompts": "^7.8.2",
|
"@inquirer/prompts": "^7.8.3",
|
||||||
"@inquirer/type": "^3.0.8",
|
"@inquirer/type": "^3.0.8",
|
||||||
"ansi-escapes": "^4.3.2",
|
"ansi-escapes": "^4.3.2",
|
||||||
"mute-stream": "^2.0.0",
|
"mute-stream": "^2.0.0",
|
||||||
|
|||||||
@ -24,7 +24,8 @@ import {
|
|||||||
TaskSchedulerService,
|
TaskSchedulerService,
|
||||||
TokenService,
|
TokenService,
|
||||||
UserService,
|
UserService,
|
||||||
FrontendEventService
|
FrontendEventService,
|
||||||
|
ParticipantNameService
|
||||||
} from '../services/index.js';
|
} from '../services/index.js';
|
||||||
|
|
||||||
export const container: Container = new Container();
|
export const container: Container = new Container();
|
||||||
@ -61,6 +62,7 @@ export const registerDependencies = () => {
|
|||||||
container.bind(FrontendEventService).toSelf().inSingletonScope();
|
container.bind(FrontendEventService).toSelf().inSingletonScope();
|
||||||
container.bind(LiveKitService).toSelf().inSingletonScope();
|
container.bind(LiveKitService).toSelf().inSingletonScope();
|
||||||
container.bind(RoomService).toSelf().inSingletonScope();
|
container.bind(RoomService).toSelf().inSingletonScope();
|
||||||
|
container.bind(ParticipantNameService).toSelf().inSingletonScope();
|
||||||
container.bind(ParticipantService).toSelf().inSingletonScope();
|
container.bind(ParticipantService).toSelf().inSingletonScope();
|
||||||
container.bind(RecordingService).toSelf().inSingletonScope();
|
container.bind(RecordingService).toSelf().inSingletonScope();
|
||||||
container.bind(OpenViduWebhookService).toSelf().inSingletonScope();
|
container.bind(OpenViduWebhookService).toSelf().inSingletonScope();
|
||||||
|
|||||||
@ -17,6 +17,10 @@ const INTERNAL_CONFIG = {
|
|||||||
PARTICIPANT_TOKEN_EXPIRATION: '2h',
|
PARTICIPANT_TOKEN_EXPIRATION: '2h',
|
||||||
RECORDING_TOKEN_EXPIRATION: '2h',
|
RECORDING_TOKEN_EXPIRATION: '2h',
|
||||||
|
|
||||||
|
// Participant name reservations
|
||||||
|
PARTICIPANT_MAX_CONCURRENT_NAME_REQUESTS: '20', // Maximum number of request by the same name at the same time allowed
|
||||||
|
PARTICIPANT_NAME_RESERVATION_TTL: '12h' as StringValue, // Time-to-live for participant name reservations
|
||||||
|
|
||||||
// Headers for API requests
|
// Headers for API requests
|
||||||
API_KEY_HEADER: 'x-api-key',
|
API_KEY_HEADER: 'x-api-key',
|
||||||
PARTICIPANT_ROLE_HEADER: 'x-participant-role',
|
PARTICIPANT_ROLE_HEADER: 'x-participant-role',
|
||||||
|
|||||||
@ -44,6 +44,9 @@ export const lkWebhookHandler = async (req: Request, res: Response) => {
|
|||||||
case 'participant_joined':
|
case 'participant_joined':
|
||||||
await lkWebhookService.handleParticipantJoined(room!, participant!);
|
await lkWebhookService.handleParticipantJoined(room!, participant!);
|
||||||
break;
|
break;
|
||||||
|
case 'participant_left':
|
||||||
|
await lkWebhookService.handleParticipantLeft(room!, participant!);
|
||||||
|
break;
|
||||||
case 'room_started':
|
case 'room_started':
|
||||||
await lkWebhookService.handleRoomStarted(room!);
|
await lkWebhookService.handleRoomStarted(room!);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -10,6 +10,11 @@ export const enum RedisKeyName {
|
|||||||
ARCHIVED_ROOM = `${RedisKeyPrefix.BASE}archived_room:`,
|
ARCHIVED_ROOM = `${RedisKeyPrefix.BASE}archived_room:`,
|
||||||
USER = `${RedisKeyPrefix.BASE}user:`,
|
USER = `${RedisKeyPrefix.BASE}user:`,
|
||||||
API_KEYS = `${RedisKeyPrefix.BASE}api_keys:`,
|
API_KEYS = `${RedisKeyPrefix.BASE}api_keys:`,
|
||||||
|
//Tracks all currently reserved participant names per room (with TTL for auto-expiration).
|
||||||
|
ROOM_PARTICIPANTS = `${RedisKeyPrefix.BASE}room_participants:`,
|
||||||
|
// Stores released numeric suffixes (per base name) in a sorted set, so that freed numbers
|
||||||
|
// can be reused efficiently instead of always incrementing to the next highest number.
|
||||||
|
PARTICIPANT_NAME_POOL = `${RedisKeyPrefix.BASE}participant_pool:`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum RedisLockPrefix {
|
export const enum RedisLockPrefix {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export * from './auth.service.js';
|
|||||||
export * from './livekit.service.js';
|
export * from './livekit.service.js';
|
||||||
export * from './frontend-event.service.js';
|
export * from './frontend-event.service.js';
|
||||||
export * from './room.service.js';
|
export * from './room.service.js';
|
||||||
|
export * from './participant-name.service.js';
|
||||||
export * from './participant.service.js';
|
export * from './participant.service.js';
|
||||||
export * from './recording.service.js';
|
export * from './recording.service.js';
|
||||||
export * from './openvidu-webhook.service.js';
|
export * from './openvidu-webhook.service.js';
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
MeetStorageService,
|
MeetStorageService,
|
||||||
MutexService,
|
MutexService,
|
||||||
OpenViduWebhookService,
|
OpenViduWebhookService,
|
||||||
|
ParticipantService,
|
||||||
RecordingService,
|
RecordingService,
|
||||||
RoomService,
|
RoomService,
|
||||||
DistributedEventService
|
DistributedEventService
|
||||||
@ -29,6 +30,7 @@ export class LivekitWebhookService {
|
|||||||
@inject(MutexService) protected mutexService: MutexService,
|
@inject(MutexService) protected mutexService: MutexService,
|
||||||
@inject(DistributedEventService) protected distributedEventService: DistributedEventService,
|
@inject(DistributedEventService) protected distributedEventService: DistributedEventService,
|
||||||
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
|
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
|
||||||
|
@inject(ParticipantService) protected participantService: ParticipantService,
|
||||||
@inject(LoggerService) protected logger: LoggerService
|
@inject(LoggerService) protected logger: LoggerService
|
||||||
) {
|
) {
|
||||||
this.webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
|
this.webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
|
||||||
@ -158,6 +160,25 @@ export class LivekitWebhookService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the 'participant_left' event by releasing the participant's reserved name
|
||||||
|
* to make it available for other participants.
|
||||||
|
* @param room - Information about the room where the participant left.
|
||||||
|
* @param participant - Information about the participant who left.
|
||||||
|
*/
|
||||||
|
async handleParticipantLeft(room: Room, participant: ParticipantInfo) {
|
||||||
|
// Skip if the participant is an egress participant
|
||||||
|
if (this.livekitService.isEgressParticipant(participant)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Release the participant's reserved name
|
||||||
|
await this.participantService.releaseParticipantName(room.name, participant.name);
|
||||||
|
this.logger.verbose(`Released name for participant '${participant.name}' in room '${room.name}'`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error releasing participant name on participant left:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles a room started event from LiveKit.
|
* Handles a room started event from LiveKit.
|
||||||
*
|
*
|
||||||
@ -210,7 +231,10 @@ export class LivekitWebhookService {
|
|||||||
tasks.push(this.roomService.bulkDeleteRooms([roomName], true));
|
tasks.push(this.roomService.bulkDeleteRooms([roomName], true));
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.push(this.recordingService.releaseRecordingLockIfNoEgress(roomName));
|
tasks.push(
|
||||||
|
this.participantService.cleanupParticipantNames(roomName),
|
||||||
|
this.recordingService.releaseRecordingLockIfNoEgress(roomName)
|
||||||
|
);
|
||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error handling room finished event: ${error}`);
|
this.logger.error(`Error handling room finished event: ${error}`);
|
||||||
|
|||||||
314
backend/src/services/participant-name.service.ts
Normal file
314
backend/src/services/participant-name.service.ts
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
import { inject, injectable } from 'inversify';
|
||||||
|
import { RedisKeyName } from '../models/redis.model.js';
|
||||||
|
import { LoggerService, RedisService } from './index.js';
|
||||||
|
import ms from 'ms';
|
||||||
|
import INTERNAL_CONFIG from '../config/internal-config.js';
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class ParticipantNameService {
|
||||||
|
private readonly MAX_CONCURRENT_NAME_REQUESTS = Number(INTERNAL_CONFIG.PARTICIPANT_MAX_CONCURRENT_NAME_REQUESTS);
|
||||||
|
private readonly PARTICIPANT_NAME_TTL = ms(INTERNAL_CONFIG.PARTICIPANT_NAME_RESERVATION_TTL);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@inject(RedisService) protected redisService: RedisService,
|
||||||
|
@inject(LoggerService) protected logger: LoggerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserves a unique participant name for a room using atomic operations.
|
||||||
|
* If the requested name is taken, it generates alternatives with incremental suffixes.
|
||||||
|
*
|
||||||
|
* @param roomId - The room identifier
|
||||||
|
* @param requestedName - The desired participant name
|
||||||
|
* @returns Promise<string> - The reserved unique name
|
||||||
|
* @throws Error if unable to reserve a unique name after max retries
|
||||||
|
*/
|
||||||
|
async reserveUniqueName(roomId: string, requestedName: string): Promise<string> {
|
||||||
|
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
|
||||||
|
|
||||||
|
// Normalize the base name for case-insensitive comparisons
|
||||||
|
const normalizedBaseName = requestedName.toLowerCase();
|
||||||
|
|
||||||
|
// First, try to reserve the exact requested name
|
||||||
|
const reservedOriginal = await this.tryReserveName(participantsKey, normalizedBaseName);
|
||||||
|
|
||||||
|
if (reservedOriginal) {
|
||||||
|
this.logger.verbose(`Reserved original name '${requestedName}' for room '${roomId}'`);
|
||||||
|
return requestedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If original name is taken, generate alternatives with atomic counter
|
||||||
|
for (let attempt = 1; attempt <= this.MAX_CONCURRENT_NAME_REQUESTS; attempt++) {
|
||||||
|
const alternativeName = await this.generateAlternativeName(roomId, normalizedBaseName, attempt);
|
||||||
|
const reserved = await this.tryReserveName(participantsKey, alternativeName);
|
||||||
|
|
||||||
|
if (reserved) {
|
||||||
|
this.logger.verbose(
|
||||||
|
`Reserved alternative name '${alternativeName}' for room '${roomId}' (attempt ${attempt})`
|
||||||
|
);
|
||||||
|
// Return alternative name with original case
|
||||||
|
const suffix = alternativeName.replace(`${normalizedBaseName}_`, '');
|
||||||
|
return `${requestedName}_${suffix}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Unable to reserve unique name for '${requestedName}' in room '${roomId}' after ${this.MAX_CONCURRENT_NAME_REQUESTS} attempts`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases a reserved participant name, making it available for others.
|
||||||
|
*
|
||||||
|
* @param roomId - The room identifier
|
||||||
|
* @param participantName - The name to release
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Releases a reserved participant name, making it available for others.
|
||||||
|
* Also returns the number suffix to the available pool for reuse.
|
||||||
|
*
|
||||||
|
* @param roomId - The room identifier
|
||||||
|
* @param participantName - The name to release
|
||||||
|
*/
|
||||||
|
async releaseName(roomId: string, participantName: string): Promise<void> {
|
||||||
|
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
|
||||||
|
// Normalize the name for case-insensitive checks
|
||||||
|
const normalizedName = participantName.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.redisService.delete(`${participantsKey}:${normalizedName}`);
|
||||||
|
|
||||||
|
// If this is a numbered variant (e.g., "Alice_2"), return the number to the pool
|
||||||
|
const numberMatch = participantName.match(/^(.+)_(\d+)$/);
|
||||||
|
|
||||||
|
if (numberMatch) {
|
||||||
|
const baseName = numberMatch[1];
|
||||||
|
const number = parseInt(numberMatch[2], 10);
|
||||||
|
await this.returnNumberToPool(roomId, baseName, number);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.verbose(`Released name '${participantName}' for room '${roomId}'`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Error releasing name '${participantName}' for room '${roomId}':`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a participant name is currently reserved in a room.
|
||||||
|
*
|
||||||
|
* @param roomId - The room identifier
|
||||||
|
* @param participantName - The name to check
|
||||||
|
* @returns Promise<boolean> - True if the name is reserved
|
||||||
|
*/
|
||||||
|
async isNameReserved(roomId: string, participantName: string): Promise<boolean> {
|
||||||
|
// Normalize the name for case-insensitive checks
|
||||||
|
const normalizedName = participantName.toLowerCase();
|
||||||
|
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
|
||||||
|
return await this.redisService.exists(`${participantsKey}:${normalizedName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all currently reserved names in a room.
|
||||||
|
*
|
||||||
|
* @param roomId - The room identifier
|
||||||
|
* @returns Promise<string[]> - Array of reserved participant names
|
||||||
|
*/
|
||||||
|
async getReservedNames(roomId: string): Promise<string[]> {
|
||||||
|
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
|
||||||
|
const pattern = `${participantsKey}:*`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keys = await this.redisService.getKeys(pattern);
|
||||||
|
return keys.map((key) => key.replace(`${participantsKey}:`, ''));
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error getting reserved names for room '${roomId}':`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up expired participant reservations for a room.
|
||||||
|
* This should be called periodically or when a room is cleaned up.
|
||||||
|
*
|
||||||
|
* @param roomId - The room identifier
|
||||||
|
*/
|
||||||
|
async cleanupExpiredReservations(roomId: string): Promise<void> {
|
||||||
|
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
|
||||||
|
const participantsPoolKey = `${RedisKeyName.PARTICIPANT_NAME_POOL}${roomId}`;
|
||||||
|
const pattern = `${participantsKey}:*`;
|
||||||
|
const poolPattern = `${participantsPoolKey}:*`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [participantKeys, poolKeys] = await Promise.all([
|
||||||
|
this.redisService.getKeys(pattern),
|
||||||
|
this.redisService.getKeys(poolPattern)
|
||||||
|
]);
|
||||||
|
this.logger.verbose(
|
||||||
|
`Found ${participantKeys.length} participant reservations to check for room '${roomId}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Redis TTL will automatically clean up expired keys, but we can force cleanup if needed
|
||||||
|
const promises = participantKeys.map((key) => this.redisService.delete(key));
|
||||||
|
await Promise.all(promises);
|
||||||
|
this.logger.verbose(
|
||||||
|
`Cleaned up ${participantKeys.length} expired participant names reservations for room '${roomId}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up expired participant name numbers from the pool
|
||||||
|
this.logger.verbose(`Found ${poolKeys.length} participant name numbers to check for room '${roomId}'`);
|
||||||
|
const poolPromises = poolKeys.map((key) => this.redisService.delete(key));
|
||||||
|
await Promise.all(poolPromises);
|
||||||
|
this.logger.verbose(`Cleaned up ${poolKeys.length} expired participant name numbers for room '${roomId}'`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error cleaning up reservations for room '${roomId}':`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to atomically reserve a specific name using Redis SET with NX (not exists) option.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param participantsKey - The Redis key prefix for participants
|
||||||
|
* @param name - The name to reserve
|
||||||
|
* @returns Promise<boolean> - True if reservation was successful
|
||||||
|
*/
|
||||||
|
private async tryReserveName(participantsKey: string, name: string): Promise<boolean> {
|
||||||
|
// Normalize the name for case-insensitive checks
|
||||||
|
const normalizedName = name.toLowerCase();
|
||||||
|
const nameKey = `${participantsKey}:${normalizedName}`;
|
||||||
|
const timestamp = Date.now().toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.redisService.setIfNotExists(nameKey, timestamp, this.PARTICIPANT_NAME_TTL);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Error trying to reserve name '${name}':`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an alternative name using a pool of available numbers.
|
||||||
|
* First tries to get a number from the available pool, then generates the next sequential number.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param roomId - The room identifier
|
||||||
|
* @param baseName - The base name to append number to
|
||||||
|
* @param fallbackSuffix - Fallback suffix if Redis fails
|
||||||
|
* @returns Promise<string> - The generated alternative name
|
||||||
|
*/
|
||||||
|
private async generateAlternativeName(roomId: string, baseName: string, fallbackSuffix: number): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Normalize the base name for case-insensitive checks
|
||||||
|
const normalizedBaseName = baseName.toLowerCase();
|
||||||
|
|
||||||
|
// First try to get an available number from the pool
|
||||||
|
const availableNumber = await this.getNumberFromPool(roomId, normalizedBaseName);
|
||||||
|
|
||||||
|
if (availableNumber !== null) {
|
||||||
|
return `${baseName}_${availableNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no number available in pool, find the next sequential number
|
||||||
|
const nextNumber = await this.findNextAvailableNumber(roomId, baseName);
|
||||||
|
return `${baseName}_${nextNumber}`;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Error generating alternative name, using fallback:`, error);
|
||||||
|
// Fallback to simple incremental suffix if Redis fails
|
||||||
|
return `${baseName}_${fallbackSuffix}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the smallest available number from the pool for reuse.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param roomId - The room identifier
|
||||||
|
* @param baseName - The base name
|
||||||
|
* @returns Promise<number | null> - Available number or null if pool is empty
|
||||||
|
*/
|
||||||
|
private async getNumberFromPool(roomId: string, baseName: string): Promise<number | null> {
|
||||||
|
const poolKey = `${RedisKeyName.PARTICIPANT_NAME_POOL}${roomId}:${baseName}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the smallest number from the sorted set and remove it atomically
|
||||||
|
const results = await this.redisService.popMinFromSortedSet(poolKey, 1);
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
const number = parseInt(results[0], 10);
|
||||||
|
this.logger.verbose(`Reusing number ${number} from pool for '${baseName}' in room '${roomId}'`);
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Error getting number from pool:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the next available sequential number by checking existing participants.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param roomId - The room identifier
|
||||||
|
* @param baseName - The base name
|
||||||
|
* @returns Promise<number> - The next available number
|
||||||
|
*/
|
||||||
|
private async findNextAvailableNumber(roomId: string, baseName: string): Promise<number> {
|
||||||
|
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
|
||||||
|
const pattern = `${participantsKey}:${baseName}_*`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingKeys = await this.redisService.getKeys(pattern);
|
||||||
|
const usedNumbers = new Set<number>();
|
||||||
|
|
||||||
|
// Extract all used numbers
|
||||||
|
for (const key of existingKeys) {
|
||||||
|
const name = key.replace(`${participantsKey}:`, '');
|
||||||
|
const numberMatch = name.match(/^.+_(\d+)$/);
|
||||||
|
|
||||||
|
if (numberMatch) {
|
||||||
|
usedNumbers.add(parseInt(numberMatch[1], 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the smallest available number starting from 1
|
||||||
|
let nextNumber = 1;
|
||||||
|
|
||||||
|
while (usedNumbers.has(nextNumber)) {
|
||||||
|
nextNumber++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.verbose(`Generated new sequential number ${nextNumber} for '${baseName}' in room '${roomId}'`);
|
||||||
|
return nextNumber;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Error finding next available number:`, error);
|
||||||
|
// Fallback to timestamp-based number if everything fails
|
||||||
|
return Date.now() % 10000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a number to the available pool for reuse.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param roomId - The room identifier
|
||||||
|
* @param baseName - The base name
|
||||||
|
* @param number - The number to return to pool
|
||||||
|
*/
|
||||||
|
private async returnNumberToPool(roomId: string, baseName: string, number: number): Promise<void> {
|
||||||
|
const poolKey = `${RedisKeyName.PARTICIPANT_NAME_POOL}${roomId}:${baseName}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add number to sorted set (score = number for natural ordering)
|
||||||
|
await this.redisService.addToSortedSet(poolKey, number, number.toString());
|
||||||
|
|
||||||
|
// Set TTL on pool key to prevent memory leaks
|
||||||
|
await this.redisService.setExpiration(poolKey, this.PARTICIPANT_NAME_TTL);
|
||||||
|
|
||||||
|
this.logger.verbose(`Returned number ${number} to pool for '${baseName}' in room '${roomId}'`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Error returning number to pool:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,11 +10,17 @@ import { ParticipantInfo } from 'livekit-server-sdk';
|
|||||||
import { MeetRoomHelper } from '../helpers/room.helper.js';
|
import { MeetRoomHelper } from '../helpers/room.helper.js';
|
||||||
import { validateMeetTokenMetadata } from '../middlewares/index.js';
|
import { validateMeetTokenMetadata } from '../middlewares/index.js';
|
||||||
import {
|
import {
|
||||||
errorParticipantAlreadyExists,
|
|
||||||
errorParticipantIdentityNotProvided,
|
errorParticipantIdentityNotProvided,
|
||||||
errorParticipantNotFound
|
errorParticipantNotFound
|
||||||
} from '../models/error.model.js';
|
} from '../models/error.model.js';
|
||||||
import { FrontendEventService, LiveKitService, LoggerService, RoomService, TokenService } from './index.js';
|
import {
|
||||||
|
FrontendEventService,
|
||||||
|
LiveKitService,
|
||||||
|
LoggerService,
|
||||||
|
ParticipantNameService,
|
||||||
|
RoomService,
|
||||||
|
TokenService
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class ParticipantService {
|
export class ParticipantService {
|
||||||
@ -23,7 +29,8 @@ export class ParticipantService {
|
|||||||
@inject(RoomService) protected roomService: RoomService,
|
@inject(RoomService) protected roomService: RoomService,
|
||||||
@inject(LiveKitService) protected livekitService: LiveKitService,
|
@inject(LiveKitService) protected livekitService: LiveKitService,
|
||||||
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
|
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
|
||||||
@inject(TokenService) protected tokenService: TokenService
|
@inject(TokenService) protected tokenService: TokenService,
|
||||||
|
@inject(ParticipantNameService) protected participantNameService: ParticipantNameService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateOrRefreshParticipantToken(
|
async generateOrRefreshParticipantToken(
|
||||||
@ -32,34 +39,49 @@ export class ParticipantService {
|
|||||||
refresh = false
|
refresh = false
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { roomId, secret, participantName, participantIdentity } = participantOptions;
|
const { roomId, secret, participantName, participantIdentity } = participantOptions;
|
||||||
|
let finalParticipantName = participantName;
|
||||||
|
let finalParticipantOptions: ParticipantOptions = participantOptions;
|
||||||
|
|
||||||
if (participantName) {
|
if (participantName) {
|
||||||
if (!refresh) {
|
if (refresh) {
|
||||||
// Check if participant with same participantName exists in the room
|
|
||||||
const participantExists = await this.participantExists(roomId, participantName, 'name');
|
|
||||||
|
|
||||||
if (participantExists) {
|
|
||||||
this.logger.verbose(`Participant '${participantName}' already exists in room '${roomId}'`);
|
|
||||||
throw errorParticipantAlreadyExists(participantName, roomId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!participantIdentity) {
|
if (!participantIdentity) {
|
||||||
throw errorParticipantIdentityNotProvided();
|
throw errorParticipantIdentityNotProvided();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.verbose(`Refreshing participant token for '${participantIdentity}' in room '${roomId}'`);
|
||||||
// Check if participant with same participantIdentity exists in the room
|
// Check if participant with same participantIdentity exists in the room
|
||||||
const participantExists = await this.participantExists(roomId, participantIdentity, 'identity');
|
const participantExists = await this.participantExists(roomId, participantIdentity, 'identity');
|
||||||
|
|
||||||
if (!participantExists) {
|
if (!participantExists) {
|
||||||
this.logger.verbose(`Participant '${participantName}' does not exist in room '${roomId}'`);
|
this.logger.verbose(`Participant '${participantIdentity}' does not exist in room '${roomId}'`);
|
||||||
throw errorParticipantNotFound(participantName, roomId);
|
throw errorParticipantNotFound(participantIdentity, roomId);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.verbose(`Generating participant token for '${participantName}' in room '${roomId}'`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Reserve a unique name for the participant
|
||||||
|
finalParticipantName = await this.participantNameService.reserveUniqueName(roomId, participantName);
|
||||||
|
this.logger.verbose(`Reserved unique name '${finalParticipantName}' for room '${roomId}'`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to reserve unique name '${participantName}' for room '${roomId}':`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update participantOptions with the final participant name
|
||||||
|
finalParticipantOptions = {
|
||||||
|
...participantOptions,
|
||||||
|
participantName: finalParticipantName
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = await this.roomService.getRoomRoleBySecret(roomId, secret);
|
const role = await this.roomService.getRoomRoleBySecret(roomId, secret);
|
||||||
const token = await this.generateParticipantToken(participantOptions, role, currentRoles);
|
const token = await this.generateParticipantToken(finalParticipantOptions, role, currentRoles);
|
||||||
this.logger.verbose(`Participant token generated for room '${roomId}'`);
|
this.logger.verbose(`Participant token generated for room '${roomId}' with name '${finalParticipantName}'`);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,4 +208,41 @@ export class ParticipantService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases a participant's reserved name when they disconnect.
|
||||||
|
* This should be called when a participant leaves the room to free up the name.
|
||||||
|
*
|
||||||
|
* @param roomId - The room identifier
|
||||||
|
* @param participantName - The participant name to release
|
||||||
|
*/
|
||||||
|
async releaseParticipantName(roomId: string, participantName: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.participantNameService.releaseName(roomId, participantName);
|
||||||
|
this.logger.verbose(`Released participant name '${participantName}' for room '${roomId}'`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Error releasing participant name '${participantName}' for room '${roomId}':`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all currently reserved participant names in a room.
|
||||||
|
* Useful for debugging and monitoring.
|
||||||
|
*
|
||||||
|
* @param roomId - The room identifier
|
||||||
|
* @returns Promise<string[]> - Array of reserved participant names
|
||||||
|
*/
|
||||||
|
async getReservedNames(roomId: string): Promise<string[]> {
|
||||||
|
return await this.participantNameService.getReservedNames(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up expired participant name reservations for a room.
|
||||||
|
* This can be called during room cleanup or periodically.
|
||||||
|
*
|
||||||
|
* @param roomId - The room identifier
|
||||||
|
*/
|
||||||
|
async cleanupParticipantNames(roomId: string): Promise<void> {
|
||||||
|
await this.participantNameService.cleanupExpiredReservations(roomId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -206,18 +206,26 @@ export class RedisService extends EventEmitter {
|
|||||||
* @returns {Promise<string>} - A promise that resolves to 'OK' if the operation is successful.
|
* @returns {Promise<string>} - A promise that resolves to 'OK' if the operation is successful.
|
||||||
* @throws {Error} - Throws an error if the value type is invalid or if there is an issue setting the value in Redis.
|
* @throws {Error} - Throws an error if the value type is invalid or if there is an issue setting the value in Redis.
|
||||||
*/
|
*/
|
||||||
async set(key: string, value: any, withTTL = true): Promise<string> {
|
async set(key: string, value: string | number | boolean | object, withTTL = true): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const valueType = typeof value;
|
const valueType = typeof value;
|
||||||
|
|
||||||
if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') {
|
if (valueType === 'string' || valueType === 'number') {
|
||||||
if (withTTL) {
|
if (withTTL) {
|
||||||
await this.redisPublisher.set(key, value, 'EX', this.DEFAULT_TTL);
|
await this.redisPublisher.set(key, value.toString(), 'EX', this.DEFAULT_TTL);
|
||||||
} else {
|
} else {
|
||||||
await this.redisPublisher.set(key, value);
|
await this.redisPublisher.set(key, value.toString());
|
||||||
|
}
|
||||||
|
} else if (valueType === 'boolean') {
|
||||||
|
const stringValue = value.toString();
|
||||||
|
|
||||||
|
if (withTTL) {
|
||||||
|
await this.redisPublisher.set(key, stringValue, 'EX', this.DEFAULT_TTL);
|
||||||
|
} else {
|
||||||
|
await this.redisPublisher.set(key, stringValue);
|
||||||
}
|
}
|
||||||
} else if (valueType === 'object') {
|
} else if (valueType === 'object') {
|
||||||
await this.redisPublisher.hmset(key, value);
|
await this.redisPublisher.hmset(key, value as Record<string, string | number>);
|
||||||
|
|
||||||
if (withTTL) await this.redisPublisher.expire(key, this.DEFAULT_TTL);
|
if (withTTL) await this.redisPublisher.expire(key, this.DEFAULT_TTL);
|
||||||
} else {
|
} else {
|
||||||
@ -250,6 +258,81 @@ export class RedisService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically sets a value only if the key doesn't exist (SET with NX option).
|
||||||
|
*
|
||||||
|
* @param {string} key - The key to set
|
||||||
|
* @param {string} value - The value to set
|
||||||
|
* @param {number} [ttlSeconds] - Optional TTL in seconds
|
||||||
|
* @returns {Promise<boolean>} - True if the key was set, false if it already existed
|
||||||
|
*/
|
||||||
|
async setIfNotExists(key: string, value: string, ttlSeconds?: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let result: string | null;
|
||||||
|
|
||||||
|
if (ttlSeconds) {
|
||||||
|
result = await this.redisPublisher.set(key, value, 'EX', ttlSeconds, 'NX');
|
||||||
|
} else {
|
||||||
|
result = (await this.redisPublisher.setnx(key, value)) ? 'OK' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result === 'OK';
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error setting value with NX option in Redis', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets an expiration time on an existing key.
|
||||||
|
*
|
||||||
|
* @param {string} key - The key to set expiration on
|
||||||
|
* @param {number} ttlSeconds - TTL in seconds
|
||||||
|
* @returns {Promise<boolean>} - True if expiration was set successfully
|
||||||
|
*/
|
||||||
|
async setExpiration(key: string, ttlSeconds: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await this.redisPublisher.expire(key, ttlSeconds);
|
||||||
|
return result === 1;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error setting expiration in Redis', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes and returns the member with the lowest score from a sorted set.
|
||||||
|
*
|
||||||
|
* @param {string} key - The key of the sorted set
|
||||||
|
* @param {number} count - Number of members to pop (default: 1)
|
||||||
|
* @returns {Promise<string[]>} - Array of popped members
|
||||||
|
*/
|
||||||
|
async popMinFromSortedSet(key: string, count = 1): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
return await this.redisPublisher.zpopmin(key, count);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error popping min from sorted set in Redis', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds one or more members to a sorted set.
|
||||||
|
*
|
||||||
|
* @param {string} key - The key of the sorted set
|
||||||
|
* @param {number} score - The score for the member
|
||||||
|
* @param {string} member - The member to add
|
||||||
|
* @returns {Promise<number>} - Number of elements added
|
||||||
|
*/
|
||||||
|
async addToSortedSet(key: string, score: number, member: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await this.redisPublisher.zadd(key, score, member);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error adding to sorted set in Redis', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
this.logger.verbose('Cleaning up Redis connections');
|
this.logger.verbose('Cleaning up Redis connections');
|
||||||
this.redisPublisher.quit();
|
this.redisPublisher.quit();
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export class TokenService {
|
|||||||
if (participantName && !participantIdentity) {
|
if (participantName && !participantIdentity) {
|
||||||
// Generate participant identity based on name and unique ID
|
// Generate participant identity based on name and unique ID
|
||||||
const identityPrefix = participantName.replace(/\s+/g, ''); // Remove all spaces
|
const identityPrefix = participantName.replace(/\s+/g, ''); // Remove all spaces
|
||||||
participantIdentity = `${identityPrefix}-${uid(5)}`;
|
participantIdentity = `${identityPrefix}-${uid(8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata: MeetTokenMetadata = {
|
const metadata: MeetTokenMetadata = {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
|
import { afterEach, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
|
||||||
import { ParticipantRole } from '../../../../src/typings/ce/participant.js';
|
import { ParticipantRole } from '../../../../src/typings/ce/participant.js';
|
||||||
import { expectValidationError, expectValidParticipantTokenResponse } from '../../../helpers/assertion-helpers.js';
|
import { expectValidationError, expectValidParticipantTokenResponse } from '../../../helpers/assertion-helpers.js';
|
||||||
import {
|
import {
|
||||||
@ -17,10 +17,14 @@ describe('Participant API Tests', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
startTestServer();
|
startTestServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
roomData = await setupSingleRoom();
|
roomData = await setupSingleRoom();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
// Force to cleanup participant name reservations after each test
|
||||||
|
afterEach(async () => {
|
||||||
await disconnectFakeParticipants();
|
await disconnectFakeParticipants();
|
||||||
await deleteAllRooms();
|
await deleteAllRooms();
|
||||||
});
|
});
|
||||||
@ -87,14 +91,35 @@ describe('Participant API Tests', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail with 409 when participant already exists in the room', async () => {
|
it('should success when participant already exists in the room', async () => {
|
||||||
roomData = await setupSingleRoom(true);
|
roomData = await setupSingleRoom(true);
|
||||||
const response = await generateParticipantToken({
|
let response = await generateParticipantToken({
|
||||||
roomId: roomData.room.roomId,
|
roomId: roomData.room.roomId,
|
||||||
secret: roomData.moderatorSecret,
|
secret: roomData.moderatorSecret,
|
||||||
participantName
|
participantName
|
||||||
});
|
});
|
||||||
expect(response.status).toBe(409);
|
|
||||||
|
// First participant using API. LK CLI participants can reuse the same name.
|
||||||
|
expectValidParticipantTokenResponse(
|
||||||
|
response,
|
||||||
|
roomData.room.roomId,
|
||||||
|
ParticipantRole.MODERATOR,
|
||||||
|
participantName
|
||||||
|
);
|
||||||
|
|
||||||
|
response = await generateParticipantToken({
|
||||||
|
roomId: roomData.room.roomId,
|
||||||
|
secret: roomData.moderatorSecret,
|
||||||
|
participantName
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second participant using API, the participant name should be unique
|
||||||
|
expectValidParticipantTokenResponse(
|
||||||
|
response,
|
||||||
|
roomData.room.roomId,
|
||||||
|
ParticipantRole.MODERATOR,
|
||||||
|
participantName + '_1'
|
||||||
|
);
|
||||||
|
|
||||||
// Recreate the room without the participant
|
// Recreate the room without the participant
|
||||||
roomData = await setupSingleRoom();
|
roomData = await setupSingleRoom();
|
||||||
|
|||||||
283
backend/tests/unit/services/participant-name.service.test.ts
Normal file
283
backend/tests/unit/services/participant-name.service.test.ts
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach, beforeAll } from '@jest/globals';
|
||||||
|
import { container, registerDependencies } from '../../../src/config/index.js';
|
||||||
|
import { ParticipantNameService } from '../../../src/services/participant-name.service.js';
|
||||||
|
import { RedisService } from '../../../src/services/redis.service.js';
|
||||||
|
import ms from 'ms';
|
||||||
|
|
||||||
|
describe('ParticipantNameService', () => {
|
||||||
|
let participantNameService: ParticipantNameService;
|
||||||
|
let redisService: RedisService;
|
||||||
|
const testRoomId = 'test-room-unique-names';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
registerDependencies();
|
||||||
|
participantNameService = container.get(ParticipantNameService);
|
||||||
|
redisService = container.get(RedisService);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clean up any existing test data
|
||||||
|
await cleanupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up test data after each test
|
||||||
|
await cleanupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function cleanupTestData() {
|
||||||
|
try {
|
||||||
|
const pattern = `ov_meet:room_participants:${testRoomId}:*`;
|
||||||
|
const keys = await redisService.getKeys(pattern);
|
||||||
|
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await redisService.delete(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
const counterPattern = `ov_meet:participant_counter:${testRoomId}:*`;
|
||||||
|
const counterKeys = await redisService.getKeys(counterPattern);
|
||||||
|
|
||||||
|
if (counterKeys.length > 0) {
|
||||||
|
await redisService.delete(counterKeys);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Reserve unique participant name', () => {
|
||||||
|
it('should reserve the original name when available', async () => {
|
||||||
|
const requestedName = 'Participant';
|
||||||
|
const reservedName = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
|
||||||
|
expect(reservedName).toBe(requestedName);
|
||||||
|
|
||||||
|
// Verify the name is actually reserved
|
||||||
|
const isReserved = await participantNameService.isNameReserved(testRoomId, requestedName);
|
||||||
|
expect(isReserved).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat names as case-insensitive if required', async () => {
|
||||||
|
await participantNameService.reserveUniqueName(testRoomId, 'Participant');
|
||||||
|
const reserved2 = await participantNameService.reserveUniqueName(testRoomId, 'participant');
|
||||||
|
expect(reserved2).toBe('participant_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate alternative names when original is taken', async () => {
|
||||||
|
const requestedName = 'Participant';
|
||||||
|
|
||||||
|
// Reserve the original name
|
||||||
|
const firstReservation = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
expect(firstReservation).toBe(requestedName);
|
||||||
|
|
||||||
|
// Try to reserve the same name again - should get alternative
|
||||||
|
const secondReservation = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
expect(secondReservation).toBe(`${requestedName}_1`);
|
||||||
|
|
||||||
|
// Try again - should get next alternative
|
||||||
|
const thirdReservation = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
expect(thirdReservation).toBe(`${requestedName}_2`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle concurrent reservations properly', async () => {
|
||||||
|
const requestedName = 'Participant';
|
||||||
|
const concurrentRequests = 5;
|
||||||
|
|
||||||
|
// Simulate concurrent requests for the same name
|
||||||
|
const reservationPromises = Array.from({ length: concurrentRequests }, () =>
|
||||||
|
participantNameService.reserveUniqueName(testRoomId, requestedName)
|
||||||
|
);
|
||||||
|
|
||||||
|
const reservedNames = await Promise.all(reservationPromises);
|
||||||
|
|
||||||
|
// All names should be unique
|
||||||
|
const uniqueNames = new Set(reservedNames);
|
||||||
|
expect(uniqueNames.size).toBe(concurrentRequests);
|
||||||
|
|
||||||
|
// First name should be the original
|
||||||
|
expect(reservedNames).toContain(requestedName);
|
||||||
|
|
||||||
|
// Others should be alternatives
|
||||||
|
for (let i = 1; i < concurrentRequests; i++) {
|
||||||
|
expect(reservedNames).toContain(`${requestedName}_${i}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reuse numbers when participants disconnect', async () => {
|
||||||
|
const requestedName = 'Participant';
|
||||||
|
|
||||||
|
// Reserve multiple names
|
||||||
|
const name1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
const name2 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
const name3 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
|
||||||
|
expect(name1).toBe('Participant');
|
||||||
|
expect(name2).toBe('Participant_1');
|
||||||
|
expect(name3).toBe('Participant_2');
|
||||||
|
|
||||||
|
// Release the middle one
|
||||||
|
await participantNameService.releaseName(testRoomId, name2);
|
||||||
|
|
||||||
|
// Next reservation should reuse the released number
|
||||||
|
const name4 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
expect(name4).toBe('Participant_1'); // Should reuse the released number
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain optimal numbering after multiple releases', async () => {
|
||||||
|
const requestedName = 'Optimized';
|
||||||
|
|
||||||
|
// Create several names
|
||||||
|
const names: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
names.push(await participantNameService.reserveUniqueName(testRoomId, requestedName));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(names).toEqual(['Optimized', 'Optimized_1', 'Optimized_2', 'Optimized_3', 'Optimized_4']);
|
||||||
|
|
||||||
|
// Release some names (simulate participants leaving)
|
||||||
|
await participantNameService.releaseName(testRoomId, 'Optimized_1');
|
||||||
|
await participantNameService.releaseName(testRoomId, 'Optimized_3');
|
||||||
|
|
||||||
|
// New participants should get the lowest available numbers
|
||||||
|
const newName1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
const newName2 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
|
||||||
|
expect(newName1).toBe('Optimized_1'); // Lowest available
|
||||||
|
expect(newName2).toBe('Optimized_3'); // Next lowest available
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('releaseName', () => {
|
||||||
|
it('should release a reserved name', async () => {
|
||||||
|
const participantName = 'Participant';
|
||||||
|
|
||||||
|
// Reserve a name
|
||||||
|
await participantNameService.reserveUniqueName(testRoomId, participantName);
|
||||||
|
expect(await participantNameService.isNameReserved(testRoomId, participantName)).toBe(true);
|
||||||
|
|
||||||
|
// Release the name
|
||||||
|
await participantNameService.releaseName(testRoomId, participantName);
|
||||||
|
expect(await participantNameService.isNameReserved(testRoomId, participantName)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow reusing a released name', async () => {
|
||||||
|
const participantName = 'Frank';
|
||||||
|
|
||||||
|
// Reserve, release, and reserve again
|
||||||
|
await participantNameService.reserveUniqueName(testRoomId, participantName);
|
||||||
|
await participantNameService.releaseName(testRoomId, participantName);
|
||||||
|
|
||||||
|
const newReservation = await participantNameService.reserveUniqueName(testRoomId, participantName);
|
||||||
|
expect(newReservation).toBe(participantName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getReservedNames', () => {
|
||||||
|
it('should return all reserved names in a room in lowercase', async () => {
|
||||||
|
const names = ['Grace', 'Henry', 'Iris'];
|
||||||
|
|
||||||
|
// Reserve multiple names
|
||||||
|
for (const name of names) {
|
||||||
|
await participantNameService.reserveUniqueName(testRoomId, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservedNames = await participantNameService.getReservedNames(testRoomId);
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
|
expect(reservedNames).toContain(name.toLowerCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no names are reserved', async () => {
|
||||||
|
const reservedNames = await participantNameService.getReservedNames(testRoomId);
|
||||||
|
expect(reservedNames).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reserve unique participant name - edge cases', () => {
|
||||||
|
it('should be able to reserve same 20 names', async () => {
|
||||||
|
const requestedName = 'LimitTest';
|
||||||
|
|
||||||
|
const promises: Promise<string>[] = [];
|
||||||
|
const twentyNames = participantNameService['MAX_CONCURRENT_NAME_REQUESTS'];
|
||||||
|
|
||||||
|
for (let i = 0; i <= twentyNames; i++) {
|
||||||
|
promises.push(participantNameService.reserveUniqueName(testRoomId, requestedName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Los primeros MAX_RETRIES deben resolverse bien
|
||||||
|
const results = await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
const fulfilled = results.filter((r) => r.status === 'fulfilled') as PromiseFulfilledResult<string>[];
|
||||||
|
const rejected = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[];
|
||||||
|
|
||||||
|
console.log(fulfilled);
|
||||||
|
expect(fulfilled.length).toBe(twentyNames + 1); // +1 for the original name
|
||||||
|
expect(rejected.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle race condition when reusing released numbers', async () => {
|
||||||
|
const requestedName = 'RaceTest';
|
||||||
|
|
||||||
|
// Try to reserve two names
|
||||||
|
const n1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
const n2 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
|
||||||
|
expect([n1, n2]).toEqual(['RaceTest', 'RaceTest_1']);
|
||||||
|
|
||||||
|
// Release _1
|
||||||
|
await participantNameService.releaseName(testRoomId, n2);
|
||||||
|
|
||||||
|
// Try to reserve again concurrently
|
||||||
|
const [c1, c2] = await Promise.all([
|
||||||
|
participantNameService.reserveUniqueName(testRoomId, requestedName),
|
||||||
|
participantNameService.reserveUniqueName(testRoomId, requestedName)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// One of them should be _1 and the other should be _2
|
||||||
|
expect([c1, c2].sort()).toEqual(['RaceTest_1', 'RaceTest_2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reuse expired names after TTL', async () => {
|
||||||
|
(participantNameService as any)['PARTICIPANT_NAME_TTL'] = ms('1ms');
|
||||||
|
const requestedName = 'TTLTest';
|
||||||
|
|
||||||
|
// Reserva con TTL muy corto (simulado)
|
||||||
|
const name = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
expect(name).toBe('TTLTest');
|
||||||
|
|
||||||
|
// Wait for TTL to expire
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, (participantNameService['PARTICIPANT_NAME_TTL'] + 1) * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to reserve again
|
||||||
|
const newName = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
expect(newName).toBe('TTLTest'); // Reuse original name
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep names isolated per room', async () => {
|
||||||
|
const requestedName = 'Isolated';
|
||||||
|
|
||||||
|
// Reserve in two different rooms
|
||||||
|
const room1Name = await participantNameService.reserveUniqueName('room1', requestedName);
|
||||||
|
const room2Name = await participantNameService.reserveUniqueName('room2', requestedName);
|
||||||
|
|
||||||
|
// Both names should be isolated
|
||||||
|
expect(room1Name).toBe(requestedName);
|
||||||
|
expect(room2Name).toBe(requestedName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat names case-insensitively if normalization is enabled', async () => {
|
||||||
|
const requestedName = 'CaseTest';
|
||||||
|
|
||||||
|
const n1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||||
|
expect(n1).toBe('CaseTest');
|
||||||
|
|
||||||
|
// Try to reserve with different casing
|
||||||
|
const n2 = await participantNameService.reserveUniqueName(testRoomId, 'casetest');
|
||||||
|
expect(n2).toBe('casetest_1'); // Should return alternative name with original case
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@ -21,7 +21,7 @@
|
|||||||
"core-js": "^3.38.1",
|
"core-js": "^3.38.1",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"livekit-server-sdk": "^2.10.2",
|
"livekit-server-sdk": "^2.10.2",
|
||||||
"openvidu-components-angular": "^3.4.0-dev14",
|
"openvidu-components-angular": "^3.4.0-dev15",
|
||||||
"rxjs": "7.8.1",
|
"rxjs": "7.8.1",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"unique-names-generator": "^4.7.1",
|
"unique-names-generator": "^4.7.1",
|
||||||
@ -13576,9 +13576,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/openvidu-components-angular": {
|
"node_modules/openvidu-components-angular": {
|
||||||
"version": "3.4.0-dev14",
|
"version": "3.4.0-dev15",
|
||||||
"resolved": "https://registry.npmjs.org/openvidu-components-angular/-/openvidu-components-angular-3.4.0-dev14.tgz",
|
"resolved": "https://registry.npmjs.org/openvidu-components-angular/-/openvidu-components-angular-3.4.0-dev15.tgz",
|
||||||
"integrity": "sha512-pvuQCgDqmweWdEn40x6DtN27A1pRBo4tVAFSAciM27EQtDXsV30k0uqC2khdrZfYeGcK1usvqcOmXEloQ4Aw9Q==",
|
"integrity": "sha512-rH/jPOx+ZgCIxbKW1+nvEh5qbJR5V6pbWs8SFWd1gV1V7wUJYoFI+iXxO0naaWHifq0rYNO0NoLpz2hjJqPQpQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -37,7 +37,7 @@
|
|||||||
"core-js": "^3.38.1",
|
"core-js": "^3.38.1",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"livekit-server-sdk": "^2.10.2",
|
"livekit-server-sdk": "^2.10.2",
|
||||||
"openvidu-components-angular": "^3.4.0-dev14",
|
"openvidu-components-angular": "^3.4.0-dev15",
|
||||||
"rxjs": "7.8.1",
|
"rxjs": "7.8.1",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"unique-names-generator": "^4.7.1",
|
"unique-names-generator": "^4.7.1",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
@if (showMeeting) {
|
@if (showMeeting) {
|
||||||
<ov-videoconference
|
<ov-videoconference
|
||||||
[token]="participantToken"
|
[token]="participantToken"
|
||||||
[participantName]="participantName"
|
|
||||||
[prejoin]="true"
|
[prejoin]="true"
|
||||||
[prejoinDisplayParticipantName]="false"
|
[prejoinDisplayParticipantName]="false"
|
||||||
[videoEnabled]="features().videoEnabled"
|
[videoEnabled]="features().videoEnabled"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user