diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..ef290c2 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,97 @@ +module.exports = { + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:node/recommended", + "plugin:prettier/recommended" + ], + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + project: "./tsconfig.json", + tsconfigRootDir: __dirname + }, + env: { + node: true, + browser: false, + es2022: true + }, + parser: "@typescript-eslint/parser", + plugins: ["@typescript-eslint"], + overrides: [ + { + files: [".eslintrc.cjs", "jest.config.cjs"], + parserOptions: { + project: null + } + }, + { + files: ["tests/utils/*.ts", "tests/*.ts"], + plugins: ["jest", "import"], + env: { + jest: true, + "jest/globals": true + }, + extends: ["plugin:jest/recommended"] + }, + { + files: ["src/client.ts"], + env: { + browser: true, + node: false + } + }, + { + files: ["src/shared/*.ts"], + env: { + browser: true, + node: true + } + } + ], + ignorePatterns: "dist/*", + rules: { + "prettier/prettier": [ + "error", + { + tabWidth: 4, + semi: true, + singleQuote: false, + printWidth: 100, + endOfLine: "auto", + trailingComma: "none" + } + ], + "node/no-unsupported-features/es-syntax": "off", + "node/no-missing-import": "off", + "comma-dangle": "off", + "no-console": "error", + "no-undef": "error", + "no-restricted-globals": ["error", "event", "self"], + "no-const-assign": ["error"], + "no-debugger": ["error"], + "no-dupe-class-members": ["error"], + "no-dupe-keys": ["error"], + "no-dupe-args": ["error"], + "no-dupe-else-if": ["error"], + "no-unsafe-negation": ["error"], + "no-duplicate-imports": ["error"], + "valid-typeof": ["error"], + "@typescript-eslint/no-unused-vars": [ + "error", + { vars: "all", args: "none", ignoreRestSiblings: false, caughtErrors: "all" } + ], + curly: ["error", "all"], + "no-restricted-syntax": ["error", "PrivateIdentifier"], + "prefer-const": [ + "error", + { + destructuring: "all", + ignoreReadBeforeAssign: true + } + ] + }, + globals: { + NodeJS: "readonly" + } +}; diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index db98f56..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,82 +0,0 @@ -// Based on Odoo's .eslintrc.js -{ - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:node/recommended", - "plugin:prettier/recommended" - ], - "parserOptions": { - "ecmaVersion": 2022, - "sourceType": "module", - "project": "./tsconfig.json" - }, - "env": { - "node": true, - "browser": false, - "es2022": true - }, - "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint"], - "overrides": [ - { - "files": ["tests/utils/*.ts", "tests/*.ts"], - "plugins": ["jest", "import"], - "env": { - "jest": true, - "jest/globals": true - }, - "extends": ["plugin:jest/recommended"] - }, - { - "files": ["src/client.ts"], - "env": { - "browser": true, - "node": false - } - }, - { - "files": ["src/shared/*.ts"], - "env": { - "browser": true, - "node": true - } - } - ], - "ignorePatterns": "dist/*", - "rules": { - "prettier/prettier": ["error", { - "tabWidth": 4, - "semi": true, - "singleQuote": false, - "printWidth": 100, - "endOfLine": "auto", - "trailingComma": "none" - }], - "node/no-unsupported-features/es-syntax": "off", - "node/no-missing-import": "off", - "comma-dangle": "off", - "no-console": "error", - "no-undef": "error", - "no-restricted-globals": ["error", "event", "self"], - "no-const-assign": ["error"], - "no-debugger": ["error"], - "no-dupe-class-members": ["error"], - "no-dupe-keys": ["error"], - "no-dupe-args": ["error"], - "no-dupe-else-if": ["error"], - "no-unsafe-negation": ["error"], - "no-duplicate-imports": ["error"], - "valid-typeof": ["error"], - "@typescript-eslint/no-unused-vars": ["error", { "vars": "all", "args": "none", "ignoreRestSiblings": false, "caughtErrors": "all" }], - "curly": ["error", "all"], - "no-restricted-syntax": ["error", "PrivateIdentifier"], - "prefer-const": ["error", { - "destructuring": "all", - "ignoreReadBeforeAssign": true - }] - }, - "globals": { - "NodeJS": "readonly" - } -} diff --git a/README.md b/README.md index 0729781..5a47ecb 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ the SFU and a [client bundle/library](#client-api-bundle) to connect to it. ## Prerequisites - [Node.js 22.16.0 (LTS)](https://nodejs.org/en/download) +- [FFmpeg 8](https://ffmpeg.org/download.html) (if using the recording feature) ## Before deployment @@ -53,6 +54,8 @@ The available environment variables are: - **MAX_BITRATE_OUT**: if set, limits the outgoing bitrate per session (user), defaults to 10mbps - **MAX_VIDEO_BITRATE**: if set, defines the `maxBitrate` of the highest encoding layer (simulcast), defaults to 4mbps - **CHANNEL_SIZE**: the maximum amount of users per channel, defaults to 100 +- **RECORDING**: enables the recording feature, defaults to false +- **RECORDING_PATH**: the path where the recordings will be saved, defaults to `${tmpDir}/recordings`. - **WORKER_LOG_LEVEL**: "none" | "error" | "warn" | "debug", will only work if `DEBUG` is properly set. - **LOG_LEVEL**: "none" | "error" | "warn" | "info" | "debug" | "verbose" - **LOG_TIMESTAMP**: adds a timestamp to the log lines, defaults to true, to disable it, set to "disable", "false", "none", "no" or "0" diff --git a/jest.config.cjs b/jest.config.cjs index 8246c99..0c9c1e1 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -6,5 +6,5 @@ module.exports = { maxWorkers: 4, preset: "ts-jest", testEnvironment: "node", - extensionsToTreatAsEsm: [".ts"], + extensionsToTreatAsEsm: [".ts"] }; diff --git a/package-lock.json b/package-lock.json index 152cfca..c1b87e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,13 +16,14 @@ "@jest/globals": "^29.6.2", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-node-resolve": "^13.0.4", - "@rollup/plugin-typescript": "^10.0.1", + "@rollup/plugin-typescript": "^12.1.2", "@types/jest": "^29.5.0", - "@types/node": "^20.5.0", + "@types/node": "^22.13.14", "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "8.32.1", - "@typescript-eslint/parser": "8.32.1", + "@typescript-eslint/eslint-plugin": "^8.46.3", + "@typescript-eslint/parser": "^8.46.3", "eslint": "8.57.1", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.25.3", "eslint-plugin-jest": "28.11.0", "eslint-plugin-node": "^11.1.0", @@ -35,7 +36,7 @@ "rollup": "^2.79.1", "rollup-plugin-license": "3.2.0", "ts-jest": "^29.3.4", - "typescript": "~5.4.3" + "typescript": "~5.9.3" }, "engines": { "node": ">=22.16.0" @@ -640,95 +641,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -779,10 +691,11 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -1259,15 +1172,6 @@ "node": ">= 8" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@rollup/plugin-commonjs": { "version": "25.0.7", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.7.tgz", @@ -1343,20 +1247,20 @@ "dev": true }, "node_modules/@rollup/plugin-typescript": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-10.0.1.tgz", - "integrity": "sha512-wBykxRLlX7EzL8BmUqMqk5zpx2onnmRMSw/l9M1sVfkJvdwfxogZQVNUM9gVMJbjRLDR5H6U0OMOrlDGmIV45A==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz", + "integrity": "sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==", "dev": true, "license": "MIT", "dependencies": { - "@rollup/pluginutils": "^5.0.1", + "@rollup/pluginutils": "^5.1.0", "resolve": "^1.22.1" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { - "rollup": "^2.14.0||^3.0.0", + "rollup": "^2.14.0||^3.0.0||^4.0.0", "tslib": "*", "typescript": ">=3.7.0" }, @@ -1534,12 +1438,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.10.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.4.tgz", - "integrity": "sha512-D08YG6rr8X90YB56tSIuBaddy/UXAA9RKJoFvrsnogAum/0pmjkgi4+2nx96A330FmioegBWmEYQ+syqCFaveg==", + "version": "22.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz", + "integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/@types/node-fetch": { @@ -1600,17 +1505,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", - "integrity": "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", + "integrity": "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/type-utils": "8.32.1", - "@typescript-eslint/utils": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/type-utils": "8.46.3", + "@typescript-eslint/utils": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -1624,9 +1529,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "@typescript-eslint/parser": "^8.46.3", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -1640,16 +1545,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.1.tgz", - "integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz", + "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/typescript-estree": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", "debug": "^4.3.4" }, "engines": { @@ -1661,18 +1566,40 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.3.tgz", + "integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.3", + "@typescript-eslint/types": "^8.46.3", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz", - "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz", + "integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1" + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1682,15 +1609,33 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz", + "integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.1.tgz", - "integrity": "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.3.tgz", + "integrity": "sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.32.1", - "@typescript-eslint/utils": "8.32.1", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3", + "@typescript-eslint/utils": "8.46.3", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1703,13 +1648,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz", - "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.3.tgz", + "integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==", "dev": true, "license": "MIT", "engines": { @@ -1721,14 +1666,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz", - "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz", + "integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", + "@typescript-eslint/project-service": "8.46.3", + "@typescript-eslint/tsconfig-utils": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1744,7 +1691,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { @@ -1774,9 +1721,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -1787,16 +1734,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz", - "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.3.tgz", + "integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/typescript-estree": "8.32.1" + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1807,18 +1754,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz", - "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz", + "integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.1", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.46.3", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1829,9 +1776,9 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1919,6 +1866,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { "node": ">=8" } @@ -1927,6 +1875,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2231,7 +2180,8 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/brace-expansion": { "version": "1.1.12", @@ -2481,6 +2431,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2491,7 +2442,8 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/combined-stream": { "version": "1.0.8", @@ -2560,6 +2512,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2727,11 +2680,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -2769,7 +2717,8 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "node_modules/error-ex": { "version": "1.3.2", @@ -2982,6 +2931,22 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -3577,32 +3542,6 @@ "is-callable": "^1.1.3" } }, - "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -4221,6 +4160,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "engines": { "node": ">=8" } @@ -4433,7 +4373,8 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -4534,20 +4475,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", @@ -5250,9 +5177,9 @@ "dev": true }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -5648,71 +5575,15 @@ } }, "node_modules/minizlib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", - "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/minizlib/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/minizlib/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minizlib/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dependencies": { - "brace-expansion": "^2.0.1" + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minizlib/node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 18" } }, "node_modules/mkdirp": { @@ -5989,11 +5860,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" - }, "node_modules/package-name-regex": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/package-name-regex/-/package-name-regex-2.0.6.tgz", @@ -6059,6 +5925,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "engines": { "node": ">=8" } @@ -6069,26 +5936,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" - }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -6628,6 +6475,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -6639,6 +6487,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "engines": { "node": ">=8" } @@ -6837,20 +6686,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -6909,18 +6745,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -7301,9 +7126,9 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7403,10 +7228,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/update-browserslist-db": { "version": "1.0.13", @@ -7512,6 +7338,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -7574,23 +7401,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index e05078b..fb4a2ca 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "odoo-sfu", "description": "Odoo's SFU server", - "version": "1.3.2", + "version": "1.4.0", "author": "Odoo", "license": "LGPL-3.0", "type": "module", @@ -30,13 +30,14 @@ "@jest/globals": "^29.6.2", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-node-resolve": "^13.0.4", - "@rollup/plugin-typescript": "^10.0.1", + "@rollup/plugin-typescript": "^12.1.2", "@types/jest": "^29.5.0", - "@types/node": "^20.5.0", + "@types/node": "^22.13.14", "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "8.32.1", - "@typescript-eslint/parser": "8.32.1", + "@typescript-eslint/eslint-plugin": "^8.46.3", + "@typescript-eslint/parser": "^8.46.3", "eslint": "8.57.1", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.25.3", "eslint-plugin-jest": "28.11.0", "eslint-plugin-node": "^11.1.0", @@ -49,6 +50,6 @@ "rollup": "^2.79.1", "rollup-plugin-license": "3.2.0", "ts-jest": "^29.3.4", - "typescript": "~5.4.3" + "typescript": "~5.9.3" } } diff --git a/rollup.config.js b/rollup.config.js index dc669db..f421cf5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -35,9 +35,6 @@ export default { plugins: [ typescript({ tsconfig: "./tsconfig_bundle.json", - declaration: false, - declarationMap: false, - sourceMap: false, }), resolve({ browser: true, diff --git a/src/client.ts b/src/client.ts index f1bb5eb..0b3a5d3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,7 +18,14 @@ import { SERVER_REQUEST, WS_CLOSE_CODE } from "#src/shared/enums.ts"; -import type { JSONSerializable, StreamType, BusMessage } from "#src/shared/types"; +import type { + AvailableFeatures, + JSONSerializable, + StreamType, + BusMessage, + RequestMessage, + StartupData +} from "#src/shared/types"; import type { TransportConfig, SessionId, SessionInfo } from "#src/models/session"; interface Consumers { @@ -55,11 +62,13 @@ export enum CLIENT_UPDATE { /** A session has left the channel */ DISCONNECT = "disconnect", /** Session info has changed */ - INFO_CHANGE = "info_change" + INFO_CHANGE = "info_change", + CHANNEL_INFO_CHANGE = "channel_info_change" } type ClientUpdatePayload = | { senderId: SessionId; message: JSONSerializable } | { sessionId: SessionId } + | { isRecording: boolean } | Record | { type: StreamType; @@ -141,6 +150,13 @@ const ACTIVE_STATES = new Set([ export class SfuClient extends EventTarget { /** Connection errors encountered */ public errors: Error[] = []; + public availableFeatures: AvailableFeatures = { + rtc: false, + recording: false, + transcription: false + }; + public isRecording: boolean = false; + public isTranscribing: boolean = false; /** Current client state */ private _state: SfuClientState = SfuClientState.DISCONNECTED; /** Communication bus */ @@ -256,6 +272,53 @@ export class SfuClient extends EventTarget { await Promise.all(proms); return stats; } + async startRecording(): Promise { + if (this.state !== SfuClientState.CONNECTED) { + return false; + } + return this._bus!.request( + { + name: CLIENT_REQUEST.START_RECORDING + }, + { batch: true } + ); + } + + async stopRecording(): Promise { + if (this.state !== SfuClientState.CONNECTED) { + return false; + } + return this._bus!.request( + { + name: CLIENT_REQUEST.STOP_RECORDING + }, + { batch: true } + ); + } + + async startTranscription(): Promise { + if (this.state !== SfuClientState.CONNECTED) { + return false; + } + return this._bus!.request( + { + name: CLIENT_REQUEST.START_TRANSCRIPTION + }, + { batch: true } + ); + } + + async stopTranscription(): Promise { + if (this.state !== SfuClientState.CONNECTED) { + return false; + } + return this._bus!.request( + { + name: CLIENT_REQUEST.STOP_TRANSCRIPTION + }, + { batch: true } + ); + } /** * Updates the server with the info of the session (isTalking, isCameraOn,...) so that it can broadcast it to the @@ -445,7 +508,15 @@ export class SfuClient extends EventTarget { */ webSocket.addEventListener( "message", - () => { + (message) => { + if (message.data) { + const { availableFeatures, isRecording, isTranscribing } = JSON.parse( + message.data + ) as StartupData; + this.availableFeatures = availableFeatures; + this.isRecording = isRecording; + this.isTranscribing = isTranscribing; + } resolve(new Bus(webSocket)); }, { once: true } @@ -488,10 +559,10 @@ export class SfuClient extends EventTarget { }); transport.on("produce", async ({ kind, rtpParameters, appData }, callback, errback) => { try { - const result = (await this._bus!.request({ + const result = await this._bus!.request({ name: CLIENT_REQUEST.INIT_PRODUCER, payload: { type: appData.type as StreamType, kind, rtpParameters } - })) as { id: string }; + }); callback({ id: result.id }); } catch (error) { errback(error as Error); @@ -558,7 +629,7 @@ export class SfuClient extends EventTarget { // Retry connecting with an exponential backoff. this._connectRetryDelay = Math.min(this._connectRetryDelay * 1.5, MAXIMUM_RECONNECT_DELAY) + 1000 * Math.random(); - const timeout = window.setTimeout(() => this._connect(), this._connectRetryDelay); + const timeout = setTimeout(() => this._connect(), this._connectRetryDelay); this._onCleanup(() => clearTimeout(timeout)); } @@ -576,10 +647,18 @@ export class SfuClient extends EventTarget { case SERVER_MESSAGE.INFO_CHANGE: this._updateClient(CLIENT_UPDATE.INFO_CHANGE, payload); break; + case SERVER_MESSAGE.CHANNEL_INFO_CHANGE: + this.isRecording = payload.isRecording; + this.isTranscribing = payload.isTranscribing; + this._updateClient(CLIENT_UPDATE.CHANNEL_INFO_CHANGE, payload); + break; } } - private async _handleRequest({ name, payload }: BusMessage): Promise { + private async _handleRequest({ + name, + payload + }: RequestMessage): Promise { switch (name) { case SERVER_REQUEST.INIT_CONSUMER: { const { id, kind, producerId, rtpParameters, sessionId, type, active } = payload; diff --git a/src/config.ts b/src/config.ts index 23474ca..ddb5fb3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,12 @@ import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; import type { RtpCodecCapability, WorkerSettings, - WebRtcServerOptions + WebRtcServerOptions, + PlainTransportOptions } from "mediasoup/node/lib/types"; // eslint-disable-next-line node/no-unpublished-import import type { ProducerOptions } from "mediasoup-client/lib/Producer"; @@ -11,6 +14,8 @@ import type { ProducerOptions } from "mediasoup-client/lib/Producer"; const FALSY_INPUT = new Set(["disable", "false", "none", "no", "0"]); type LogLevel = "none" | "error" | "warn" | "info" | "debug" | "verbose"; type WorkerLogLevel = "none" | "error" | "warn" | "debug"; +const testingMode = Boolean(process.env.JEST_WORKER_ID); +export const tmpDir = path.join(os.tmpdir(), "odoo_sfu"); // ------------------------------------------------------------ // ------------------ ENV VARIABLES ----------------------- @@ -22,7 +27,7 @@ type WorkerLogLevel = "none" | "error" | "warn" | "debug"; * e.g: AUTH_KEY=u6bsUQEWrHdKIuYplirRnbBmLbrKV5PxKG7DtA71mng= */ export const AUTH_KEY: string = process.env.AUTH_KEY!; -if (!AUTH_KEY && !process.env.JEST_WORKER_ID) { +if (!AUTH_KEY && !testingMode) { throw new Error( "AUTH_KEY env variable is required, it is not possible to authenticate requests without it" ); @@ -34,7 +39,7 @@ if (!AUTH_KEY && !process.env.JEST_WORKER_ID) { * e.g: PUBLIC_IP=190.165.1.70 */ export const PUBLIC_IP: string = process.env.PUBLIC_IP!; -if (!PUBLIC_IP && !process.env.JEST_WORKER_ID) { +if (!PUBLIC_IP && !testingMode) { throw new Error( "PUBLIC_IP env variable is required, clients cannot establish webRTC connections without it" ); @@ -64,6 +69,26 @@ export const HTTP_INTERFACE: string = process.env.HTTP_INTERFACE || "0.0.0.0"; */ export const PORT: number = Number(process.env.PORT) || 8070; +/** + * Whether the recording feature is enabled, false by default. + */ +export const RECORDING: boolean = Boolean(process.env.RECORDING) || testingMode; +/** + * The path where the recordings will be saved, defaults to `${tmpDir}/recordings`. + */ +export const RECORDING_PATH: string = process.env.RECORDING_PATH || path.join(tmpDir, "recordings"); +if (RECORDING) { + fs.mkdirSync(RECORDING_PATH, { recursive: true }); +} +/** + * The path use by the resources service for temporary files, defaults to `${tmpDir}/resources`, + * Keeping the default is fine as this is only used for temporary files used for internal process, but it can + * be changed for debugging. + */ +export const RESOURCES_PATH: string = process.env.RESOURCES_PATH || path.join(tmpDir, "resources"); +if (RECORDING) { + fs.mkdirSync(RESOURCES_PATH, { recursive: true }); +} /** * The number of workers to spawn (up to core limits) to manage RTC servers. * 0 < NUM_WORKERS <= os.availableParallelism() @@ -194,7 +219,27 @@ export const timeouts: TimeoutConfig = Object.freeze({ // how long before a channel is closed after the last session leaves channel: 60 * 60_000, // how long to wait to gather messages before sending through the bus - busBatch: process.env.JEST_WORKER_ID ? 10 : 300 + busBatch: testingMode ? 10 : 300 +}); + +export const recording = Object.freeze({ + routingInterface: "127.0.0.1", + directory: RECORDING_PATH, + enabled: RECORDING, + maxDuration: 1000 * 60 * 60, // 1 hour, could be a env-var. + fileTTL: 1000 * 60 * 60 * 24, // 24 hours + fileExtension: "mp4", + videoCodec: "libx264", + audioCodec: "aac", + audioLimit: 20, + cameraLimit: 4, // how many camera can be merged into one recording + screenLimit: 1 +}); + +// TODO: This should probably be env variable, and at least documented so that deployment can open these ports. +export const dynamicPorts = Object.freeze({ + min: 50000, + max: 59999 }); // how many errors can occur before the session is closed, recovery attempts will be made until this limit is reached @@ -215,6 +260,7 @@ const baseProducerOptions: ProducerOptions = { export interface RtcConfig { readonly workerSettings: WorkerSettings; readonly rtcServerOptions: WebRtcServerOptions; + readonly plainTransportOptions: PlainTransportOptions; readonly rtcTransportOptions: { readonly maxSctpMessageSize: number; readonly sctpSendBufferSize: number; @@ -228,7 +274,9 @@ export interface RtcConfig { export const rtc: RtcConfig = Object.freeze({ // https://mediasoup.org/documentation/v3/mediasoup/api/#WorkerSettings workerSettings: { - logLevel: WORKER_LOG_LEVEL + logLevel: WORKER_LOG_LEVEL, + rtcMinPort: RTC_MIN_PORT, + rtcMaxPort: RTC_MAX_PORT }, // https://mediasoup.org/documentation/v3/mediasoup/api/#WebRtcServer-dictionaries rtcServerOptions: { @@ -258,6 +306,11 @@ export const rtc: RtcConfig = Object.freeze({ maxSctpMessageSize: MAX_BUF_IN, sctpSendBufferSize: MAX_BUF_OUT }, + plainTransportOptions: { + listenIp: { ip: "0.0.0.0", announcedIp: PUBLIC_IP }, + rtcpMux: true, + comedia: false + }, producerOptionsByKind: { /** Audio producer options */ audio: baseProducerOptions, diff --git a/src/models/channel.ts b/src/models/channel.ts index 91889cc..18e42a3 100644 --- a/src/models/channel.ts +++ b/src/models/channel.ts @@ -12,7 +12,9 @@ import { type SessionId, type SessionInfo } from "#src/models/session.ts"; -import { getWorker, type RtcWorker } from "#src/services/rtc.ts"; +import { Recorder } from "#src/models/recorder.ts"; +import { getWorker, type RtcWorker } from "#src/services/resources.ts"; +import { SERVER_MESSAGE } from "#src/shared/enums.ts"; const logger = new Logger("CHANNEL"); @@ -53,6 +55,7 @@ interface ChannelCreateOptions { key?: string; /** Whether to enable WebRTC functionality */ useWebRtc?: boolean; + recordingAddress?: string | null; } interface JoinResult { /** The channel instance */ @@ -83,6 +86,8 @@ export class Channel extends EventEmitter { public readonly key?: Buffer; /** mediasoup Router for media routing */ public readonly router?: Router; + /** Manages the recording of this channel, undefined if the feature is disabled */ + public readonly recorder?: Recorder; /** Active sessions in this channel */ public readonly sessions = new Map(); /** mediasoup Worker handling this channel */ @@ -102,7 +107,7 @@ export class Channel extends EventEmitter { issuer: string, options: ChannelCreateOptions = {} ): Promise { - const { key, useWebRtc = true } = options; + const { key, useWebRtc = true, recordingAddress } = options; const safeIssuer = `${remoteAddress}::${issuer}`; const oldChannel = Channel.recordsByIssuer.get(safeIssuer); if (oldChannel) { @@ -112,7 +117,7 @@ export class Channel extends EventEmitter { const channelOptions: ChannelCreateOptions & { worker?: Worker; router?: Router; - } = { key }; + } = { key, recordingAddress: useWebRtc ? recordingAddress : null }; if (useWebRtc) { channelOptions.worker = await getWorker(); channelOptions.router = await channelOptions.worker.createRouter({ @@ -125,6 +130,8 @@ export class Channel extends EventEmitter { logger.info( `created channel ${channel.uuid} (${key ? "unique" : "global"} key) for ${safeIssuer}` ); + logger.verbose(`rtc feature: ${Boolean(channel.router)}`); + logger.verbose(`recording feature: ${Boolean(channel.recorder)}`); const onWorkerDeath = () => { logger.warn(`worker died, closing channel ${channel.uuid}`); channel.close(); @@ -183,13 +190,16 @@ export class Channel extends EventEmitter { const now = new Date(); this.createDate = now.toISOString(); this.remoteAddress = remoteAddress; + this._worker = worker; + this.router = router; + this.recorder = + this.router && config.recording.enabled && options.recordingAddress + ? new Recorder(this, options.recordingAddress) + : undefined; + this.recorder?.on("update", () => this._broadcastState()); this.key = key ? Buffer.from(key, "base64") : undefined; this.uuid = crypto.randomUUID(); this.name = `${remoteAddress}*${this.uuid.slice(-5)}`; - this.router = router; - this._worker = worker; - - // Bind event handlers this._onSessionClose = this._onSessionClose.bind(this); } @@ -199,7 +209,7 @@ export class Channel extends EventEmitter { uuid: this.uuid, remoteAddress: this.remoteAddress, sessionsStats: await this.getSessionsStats(), - webRtcEnabled: Boolean(this._worker) + webRtcEnabled: Boolean(this.router) }; } @@ -295,6 +305,7 @@ export class Channel extends EventEmitter { * @fires Channel#close */ close(): void { + this.recorder?.terminate(); for (const session of this.sessions.values()) { session.off("close", this._onSessionClose); session.close({ code: SESSION_CLOSE_CODE.CHANNEL_CLOSED }); @@ -309,6 +320,29 @@ export class Channel extends EventEmitter { this.emit("close", this.uuid); } + /** + * Broadcast the state of this channel to all its participants + */ + private _broadcastState() { + for (const session of this.sessions.values()) { + // TODO maybe the following should be on session and some can be made in common with the startupData getter. + if (!session.bus) { + logger.warn(`tried to broadcast state to session ${session.id}, but had no Bus`); + continue; + } + session.bus.send( + { + name: SERVER_MESSAGE.CHANNEL_INFO_CHANGE, + payload: { + isRecording: Boolean(this.recorder?.isRecording), + isTranscribing: Boolean(this.recorder?.isTranscribing) + } + }, + { batch: true } + ); + } + } + /** * @param event - Close event with session ID * @fires Channel#sessionLeave diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts new file mode 100644 index 0000000..947877b --- /dev/null +++ b/src/models/ffmpeg.ts @@ -0,0 +1,156 @@ +/* eslint-disable prettier/prettier */ +import { spawn, ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import { Readable } from "node:stream"; + +import { Logger } from "#src/utils/utils.ts"; +import type { rtpData } from "#src/models/media_output"; +import { recording } from "#src/config.ts"; + +const logger = new Logger("FFMPEG"); +export class FFMPEG { + private readonly _rtp: rtpData; + private _process?: ChildProcess; + private _isClosed = false; + private readonly _filename: string; + private _logStream?: fs.WriteStream; + + constructor(rtp: rtpData, filename: string) { + this._rtp = rtp; + this._filename = filename; + logger.verbose(`creating FFMPEG for ${this._filename}`); + this._init(); + } + + close() { + if (this._isClosed) { + return; + } + this._isClosed = true; + this._logStream?.end(); + logger.verbose(`closing FFMPEG ${this._filename}`); + if (this._process && !this._process.killed) { + this._process!.kill("SIGINT"); + logger.verbose(`FFMPEG ${this._filename} SIGINT sent`); + } + } + + private _init() { + try { + const sdpString = this._createSdpText(); + logger.verbose(`FFMPEG ${this._filename} SDP:\n${sdpString}`); + + const sdpStream = Readable.from([sdpString]); + const args = this._getCommandArgs(); + + logger.verbose(`spawning ffmpeg with args: ${args.join(" ")}`); + + /** + * while testing spawn should be mocked so that we don't make subprocesses and save files + * just test if the args are correct (ffmpeg / sdp compliant) + */ + logger.debug(`TODO: mock spawn`); + this._process = spawn("ffmpeg", args); + + this._logStream = fs.createWriteStream(`${this._filename}.log`); + + this._process.stderr?.pipe(this._logStream, { end: false }); + this._process.stdout?.pipe(this._logStream, { end: false }); + + this._process.on("error", (error) => { + logger.error(`ffmpeg ${this._filename} error: ${error.message}`); + this.close(); + }); + + this._process.on("close", (code) => { + logger.verbose(`ffmpeg ${this._filename} exited with code ${code}`); + }); + + sdpStream.on("error", (error) => { + logger.error(`sdpStream error: ${error.message}`); + }); + + if (this._process.stdin) { + sdpStream.pipe(this._process.stdin); + } + } catch (error) { + logger.error(`Failed to initialize FFMPEG ${this._filename}: ${error}`); + this.close(); + } + } + + private _createSdpText(): string { + // TODO docstring on sdp text + const { port, payloadType, codec, clockRate, channels, kind } = this._rtp; + + if (!port || !payloadType || !codec || !clockRate || !kind) { + throw new Error("RTP missing required properties for SDP generation"); + } + + let sdp = `v=0\n`; + sdp += `o=- 0 0 IN IP4 ${recording.routingInterface}\n`; + sdp += `s=FFmpeg\n`; + sdp += `c=IN IP4 ${recording.routingInterface}\n`; + sdp += `t=0 0\n`; + sdp += `m=${kind} ${port} RTP/AVP ${payloadType}\n`; + sdp += `a=rtpmap:${payloadType} ${codec}/${clockRate}`; + + if (kind === "audio" && channels) { + sdp += `/${channels}`; + } + sdp += `\na=rtcp-mux`; + sdp += `\na=recvonly\n`; + return sdp; + } + + private _getContainerExtension(): string { + const codec = this._rtp.codec?.toLowerCase(); + + switch (codec) { + case "h264": + case "h265": + return "mp4"; + + case "vp8": + case "vp9": + case "av1": + case "opus": + case "vorbis": + return "webm"; + + case "pcmu": + case "pcma": + // G.711 codecs - use WAV container for raw PCM audio + return "wav"; + + default: + logger.warn(`Unknown codec "${codec}", using .mkv container as fallback`); + return "mkv"; + } + } + + private _getCommandArgs(): string[] { + // TODO docstring on command args + let args = [ + "-loglevel", "debug", // TODO remove + "-protocol_whitelist", "pipe,udp,rtp", + "-fflags", "+genpts", + "-f", "sdp", + "-i", "pipe:0" + ]; + if (this._rtp.kind === "audio") { + args = args.concat([ + "-map", "0:a:0", + "-c:a", "copy" + ]); + } else { + args = args.concat([ + "-map", "0:v:0", + "-c:v", "copy" + ]); + } + const extension = this._getContainerExtension(); + args.push(`${this._filename}.${extension}`); + return args; + } +} diff --git a/src/models/media_output.ts b/src/models/media_output.ts new file mode 100644 index 0000000..7525f4d --- /dev/null +++ b/src/models/media_output.ts @@ -0,0 +1,139 @@ +import { EventEmitter } from "node:events"; +import path from "node:path"; + +import type { + Router, + Producer, + Consumer, + PlainTransport, + MediaKind +} from "mediasoup/node/lib/types"; + +import { DynamicPort } from "#src/services/resources.ts"; +import { recording, rtc } from "#src/config.ts"; +import { FFMPEG } from "#src/models/ffmpeg.ts"; +import { Logger } from "#src/utils/utils.ts"; + +const logger = new Logger("MEDIA_OUTPUT"); + +export type rtpData = { + payloadType?: number; + clockRate?: number; + codec?: string; + kind?: MediaKind; + channels?: number; + port: number; +}; +export class MediaOutput extends EventEmitter { + name: string; + private _router: Router; + private _producer: Producer; + private _transport?: PlainTransport; + private _consumer?: Consumer; + private _ffmpeg?: FFMPEG; + private _rtpData?: rtpData; + private _port?: DynamicPort; + private _isClosed = false; + private _directory: string; + + get port() { + return this._port?.number; + } + + constructor({ + producer, + router, + name, + directory + }: { + producer: Producer; + router: Router; + name: string; + directory: string; + }) { + super(); + this._router = router; + this._producer = producer; + this.name = name; + this._directory = directory; + this._init(); + } + + close() { + this._isClosed = true; + this._cleanup(); + } + + private async _init() { + try { + this._port = new DynamicPort(); + this._transport = await this._router?.createPlainTransport(rtc.plainTransportOptions); + if (!this._transport) { + throw new Error(`Failed at creating a plain transport for`); + } + this._transport.connect({ + ip: recording.routingInterface, + port: this._port.number + }); + this._consumer = await this._transport.consume({ + producerId: this._producer.id, + rtpCapabilities: this._router!.rtpCapabilities, + paused: true + }); + if (this._isClosed) { + // may be closed by the time the consumer is created + this._cleanup(); + return; + } + const codecData = this._consumer.rtpParameters.codecs[0]; + this._rtpData = { + kind: this._producer.kind, + payloadType: codecData.payloadType, + clockRate: codecData.clockRate, + port: this._port.number, + codec: codecData.mimeType.split("/")[1], + channels: this._producer.kind === "audio" ? codecData.channels : undefined + }; + if (this._isClosed) { + this._cleanup(); + return; + } + const refreshProcess = this._refreshProcess.bind(this); + this._consumer.on("producerresume", refreshProcess); + this._consumer.on("producerpause", refreshProcess); + this._refreshProcess(); + } catch { + this.close(); + } + } + + private async _refreshProcess() { + if (this._isClosed || !this._rtpData) { + return; + } + if (this._producer.paused) { + logger.debug(`pausing consumer ${this._consumer?.id}`); + this._consumer?.pause(); + logger.debug("TODO notify pause"); + } else { + logger.debug(`resuming consumer ${this._consumer?.id}`); + if (!this._ffmpeg) { + const fileName = `${this.name}-${Date.now()}`; + logger.verbose(`writing ${fileName} at ${this._directory}`); + const fullName = path.join(this._directory, fileName); + this._ffmpeg = new FFMPEG(this._rtpData, fullName); + logger.verbose(`resuming consumer ${this._consumer?.id}`); + this.emit("file", fileName); + logger.debug("TODO notify resume"); + } + this._consumer?.resume(); + } + } + + private _cleanup() { + this._ffmpeg?.close(); + this._consumer?.close(); + this._transport?.close(); + this._port?.release(); + } +} diff --git a/src/models/recorder.ts b/src/models/recorder.ts new file mode 100644 index 0000000..1ece1b1 --- /dev/null +++ b/src/models/recorder.ts @@ -0,0 +1,223 @@ +import { EventEmitter } from "node:events"; +import path from "node:path"; + +import { recording } from "#src/config.ts"; +import { getFolder, type Folder } from "#src/services/resources.ts"; +import { RecordingTask, type RecordingStates } from "#src/models/recording_task.ts"; +import { Logger } from "#src/utils/utils.ts"; + +import type { Channel } from "#src/models/channel"; +import type { SessionId } from "#src/models/session.ts"; + +export enum TIME_TAG { + RECORDING_STARTED = "recording_started", + RECORDING_STOPPED = "recording_stopped", + TRANSCRIPTION_STARTED = "transcription_started", + TRANSCRIPTION_STOPPED = "transcription_stopped", + NEW_FILE = "new_file" +} +export enum RECORDER_STATE { + STARTED = "started", + STOPPING = "stopping", + STOPPED = "stopped" +} +export type Metadata = { + uploadAddress: string; + timeStamps: Record>; +}; + +const logger = new Logger("RECORDER"); + +/** + * TODO some docstring + * The recorder generates a "raw" file bundle, of recordings of individual audio and video streams, + * accompanied with a metadata file describing the recording (timestamps, ids,...). + * + * These raw recordings can then be used for further processing (transcription, compilation,...). + * + * Recorder acts at the channel level, managing the creation and closure of sessions in that channel, + * whereas the recording_task acts at the session level, managing the recording of an individual session + * and following its producer lifecycle. + */ +export class Recorder extends EventEmitter { + /** + * Plain recording means that we mark the recording to be saved as a audio/video file + **/ + isRecording: boolean = false; + /** + * Transcribing means that we mark the audio for being transcribed later, + * this captures only the audio of the call. + **/ + isTranscribing: boolean = false; + state: RECORDER_STATE = RECORDER_STATE.STOPPED; + private _folder?: Folder; + private readonly _channel: Channel; // TODO rename with private prefix + private readonly _tasks = new Map(); + /** Path to which the final recording will be uploaded to */ + private readonly _metaData: Metadata = { + uploadAddress: "", + timeStamps: {} + }; + + get isActive(): boolean { + return this.state === RECORDER_STATE.STARTED; + } + + get path(): string | undefined { + return this._folder?.path; + } + + constructor(channel: Channel, recordingAddress: string) { + super(); + this._onSessionJoin = this._onSessionJoin.bind(this); + this._onSessionLeave = this._onSessionLeave.bind(this); + this._channel = channel; + this._metaData.uploadAddress = recordingAddress; + } + + async start() { + // TODO: for the transcription, we should play with isRecording / isTranscribing to see whether to stop or start or just disabled one of the features + if (!this.isRecording) { + this.isRecording = true; + this.mark(TIME_TAG.RECORDING_STARTED); + await this._refreshConfiguration(); + } + return this.isRecording; + } + + async stop() { + if (this.isRecording) { + this.isRecording = false; + this.mark(TIME_TAG.RECORDING_STOPPED); + await this._refreshConfiguration(); + } + return this.isRecording; + } + + async startTranscription() { + if (!this.isTranscribing) { + this.isTranscribing = true; + this.mark(TIME_TAG.TRANSCRIPTION_STARTED); + await this._refreshConfiguration(); + } + return this.isTranscribing; + } + + async stopTranscription() { + if (this.isTranscribing) { + this.isTranscribing = false; + this.mark(TIME_TAG.TRANSCRIPTION_STOPPED); + await this._refreshConfiguration(); + } + return this.isTranscribing; + } + + mark(tag: TIME_TAG, value: object = {}) { + const events = this._metaData.timeStamps[Date.now()] || []; + events.push({ + tag, + value + }); + logger.debug(`Marking ${tag} for channel ${this._channel.name}`); + this._metaData.timeStamps[Date.now()] = events; + } + + /** + * @param param0 + * @param param0.save - whether to save the recording + */ + async terminate({ save = false }: { save?: boolean } = {}) { + if (!this.isActive) { + return; + } + logger.verbose(`terminating recorder for channel ${this._channel.name}`); + this._channel.off("sessionJoin", this._onSessionJoin); + this._channel.off("sessionLeave", this._onSessionLeave); + this.isRecording = false; + this.isTranscribing = false; + this.state = RECORDER_STATE.STOPPING; + this._stopTasks(); // may want to make it async (resolve on child process close/exit) so we can wait for the end of ffmpeg, when files are no longer written on. to check. + if (save) { + await this._folder?.add("metadata.json", JSON.stringify(this._metaData)); + await this._folder?.seal( + path.join(recording.directory, `${this._channel.name}_${Date.now()}`) + ); + } else { + await this._folder?.delete(); + } + this._folder = undefined; + this._metaData.timeStamps = {}; + this.state = RECORDER_STATE.STOPPED; + } + + private _onSessionJoin(id: SessionId) { + const session = this._channel.sessions.get(id); + if (!session) { + return; + } + this._tasks.set(session.id, new RecordingTask(this, session, this._getRecordingStates())); + } + + private _onSessionLeave(id: SessionId) { + const task = this._tasks.get(id); + if (task) { + task.stop(); + this._tasks.delete(id); + } + } + + private async _refreshConfiguration() { + if (this.isRecording || this.isTranscribing) { + if (this.isActive) { + await this._update().catch(async () => { + logger.warn(`Failed to update recording or ${this._channel.name}`); + await this.terminate(); + }); + } else { + await this._init().catch(async () => { + logger.error(`Failed to start recording or ${this._channel.name}`); + await this.terminate(); + }); + } + } else { + await this.terminate({ save: true }); // todo check if we always want to save here + } + this.emit("update", { isRecording: this.isRecording, isTranscribing: this.isTranscribing }); + } + + private async _update() { + const params = this._getRecordingStates(); + for (const task of this._tasks.values()) { + Object.assign(task, params); + } + } + + private async _init() { + this.state = RECORDER_STATE.STARTED; + this._folder = await getFolder(); + logger.verbose(`Initializing recorder for channel: ${this._channel.name}`); + for (const [sessionId, session] of this._channel.sessions) { + this._tasks.set( + sessionId, + new RecordingTask(this, session, this._getRecordingStates()) + ); + } + this._channel.on("sessionJoin", this._onSessionJoin); + this._channel.on("sessionLeave", this._onSessionLeave); + } + + private _stopTasks() { + for (const task of this._tasks.values()) { + task.stop(); + } + this._tasks.clear(); + } + + private _getRecordingStates(): RecordingStates { + return { + audio: this.isRecording || this.isTranscribing, + camera: this.isRecording, + screen: this.isRecording + }; + } +} diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts new file mode 100644 index 0000000..4c5db03 --- /dev/null +++ b/src/models/recording_task.ts @@ -0,0 +1,145 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { EventEmitter } from "node:events"; + +import type { Producer } from "mediasoup/node/lib/types"; + +import { MediaOutput } from "#src/models/media_output.ts"; +import { Session } from "#src/models/session.ts"; +import { Logger } from "#src/utils/utils.ts"; +import { TIME_TAG, type Recorder } from "#src/models/recorder.ts"; + +import { STREAM_TYPE } from "#src/shared/enums.ts"; + +export type RecordingStates = { + audio: boolean; + camera: boolean; + screen: boolean; +}; + +export enum RECORDING_TASK_EVENT { + UPDATE = "update" +} + +type RecordingData = { + active: boolean; // active is different from boolean(ffmpeg) so we can flag synchronously and avoid race conditions + type: STREAM_TYPE; + mediaOutput?: MediaOutput; +}; + +type RecordingDataByStreamType = { + [STREAM_TYPE.AUDIO]: RecordingData; + [STREAM_TYPE.CAMERA]: RecordingData; + [STREAM_TYPE.SCREEN]: RecordingData; +}; + +const logger = new Logger("RECORDING_TASK"); + +// TODO docstring +export class RecordingTask extends EventEmitter { + private _session: Session; + private _recorder: Recorder; + private readonly recordingDataByStreamType: RecordingDataByStreamType = { + [STREAM_TYPE.AUDIO]: { + active: false, + type: STREAM_TYPE.AUDIO + }, + [STREAM_TYPE.CAMERA]: { + active: false, + type: STREAM_TYPE.CAMERA + }, + [STREAM_TYPE.SCREEN]: { + active: false, + type: STREAM_TYPE.SCREEN + } + }; + + set audio(value: boolean) { + this._setRecording(STREAM_TYPE.AUDIO, value); + } + set camera(value: boolean) { + this._setRecording(STREAM_TYPE.CAMERA, value); + } + set screen(value: boolean) { + this._setRecording(STREAM_TYPE.SCREEN, value); + } + + constructor(recorder: Recorder, session: Session, { audio, camera, screen }: RecordingStates) { + super(); + this._onSessionProducer = this._onSessionProducer.bind(this); + this._session = session; + this._recorder = recorder; + this._session.on("producer", this._onSessionProducer); + this.audio = audio; + this.camera = camera; + this.screen = screen; + } + + private async _setRecording(type: STREAM_TYPE, state: boolean) { + const data = this.recordingDataByStreamType[type]; + if (data.active === state) { + return; + } + data.active = state; + const producer = this._session.producers[type]; + if (!producer) { + return; // will be handled later when the session starts producing + } + this._updateProcess(data, producer, type); + } + + private async _onSessionProducer({ + type, + producer + }: { + type: STREAM_TYPE; + producer: Producer; + }) { + const data = this.recordingDataByStreamType[type]; + if (!data.active) { + return; + } + this._updateProcess(data, producer, type); + } + + private async _updateProcess(data: RecordingData, producer: Producer, type: STREAM_TYPE) { + if (data.active) { + if (data.mediaOutput) { + // already recording + return; + } + try { + data.mediaOutput = new MediaOutput({ + producer, + router: this._session.router!, + name: `${this._session.id}-${type}`, + directory: this._recorder.path! + }); + data.mediaOutput.on("file", (filename: string) => { + this._recorder.mark(TIME_TAG.NEW_FILE, { filename, type }); + }); + if (data.active) { + return; + } + } catch { + logger.warn( + `failed at starting the recording for ${this._session.name} ${data.type}` + ); + } + } + this._clearData(data.type); + } + + private _clearData(type: STREAM_TYPE) { + const data = this.recordingDataByStreamType[type]; + data.active = false; + data.mediaOutput?.close(); + data.mediaOutput = undefined; + } + + stop() { + this._session.off("producer", this._onSessionProducer); + for (const type of Object.values(STREAM_TYPE)) { + this._clearData(type); + } + } +} diff --git a/src/models/session.ts b/src/models/session.ts index 40c0884..dd67e0e 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -1,14 +1,14 @@ import { EventEmitter } from "node:events"; import type { - IceParameters, - IceCandidate, - DtlsParameters, - SctpParameters, Consumer, + DtlsParameters, + IceCandidate, + IceParameters, Producer, - WebRtcTransport, - RtpCapabilities + RtpCapabilities, + SctpParameters, + WebRtcTransport } from "mediasoup/node/lib/types"; import * as config from "#src/config.ts"; @@ -20,7 +20,13 @@ import { SERVER_REQUEST, STREAM_TYPE } from "#src/shared/enums.ts"; -import type { JSONSerializable, StreamType, BusMessage } from "#src/shared/types"; +import type { + BusMessage, + JSONSerializable, + RequestMessage, + StartupData, + StreamType +} from "#src/shared/types"; import type { Bus } from "#src/shared/bus.ts"; import type { Channel } from "#src/models/channel.ts"; @@ -56,6 +62,10 @@ export enum SESSION_CLOSE_CODE { KICKED = "kicked", ERROR = "error" } +export interface SessionPermissions { + recording?: boolean; + transcription?: boolean; +} export interface TransportConfig { /** Transport identifier */ id: string; @@ -107,6 +117,7 @@ const logger = new Logger("SESSION"); * * @fires Session#stateChange - Emitted when session state changes * @fires Session#close - Emitted when session is closed + * @fires Session#producer - Emitted when a new producer is created */ export class Session extends EventEmitter { /** Communication bus for WebSocket messaging */ @@ -135,6 +146,10 @@ export class Session extends EventEmitter { camera: null, screen: null }; + public readonly permissions: SessionPermissions = Object.seal({ + recording: false, + transcription: false + }); /** Parent channel containing this session */ private readonly _channel: Channel; /** Recovery timeouts for failed consumers */ @@ -161,6 +176,19 @@ export class Session extends EventEmitter { this.setMaxListeners(config.CHANNEL_SIZE * 2); } + get startupData(): StartupData { + return { + availableFeatures: { + rtc: Boolean(this._channel.router), + recording: Boolean(this._channel.recorder && this.permissions.recording), + transcription: Boolean(this._channel.recorder && this.permissions.transcription) + }, + // TODO could be a channelState type + isRecording: this._channel.recorder?.isRecording || false, + isTranscribing: this._channel.recorder?.isTranscribing || false + }; + } + get name(): string { return `${this._channel.name}:${this.id}@${this.remote}`; } @@ -169,11 +197,33 @@ export class Session extends EventEmitter { return this._state; } + get router() { + return this._channel.router; + } + set state(state: SESSION_STATE) { this._state = state; + /** + * @event Session#stateChange + * @type {{ state: SESSION_STATE }} + */ this.emit("stateChange", state); } + updatePermissions(permissions: SessionPermissions | undefined): void { + if (!permissions) { + return; + } + for (const key of Object.keys(this.permissions) as (keyof SessionPermissions)[]) { + const newVal = permissions[key]; + if (newVal === undefined) { + continue; + } + this.permissions[key] = Boolean(permissions[key]); + logger.verbose(`Permissions updated: ${key} = ${this.permissions[key]}`); + } + } + async getProducerBitRates(): Promise { const bitRates: ProducerBitRates = {}; const proms: Promise[] = []; @@ -323,15 +373,15 @@ export class Session extends EventEmitter { this._ctsTransport?.close(); this._stcTransport?.close(); }); - this._clientCapabilities = (await this.bus!.request({ + this._clientCapabilities = await this.bus!.request({ name: SERVER_REQUEST.INIT_TRANSPORTS, payload: { capabilities: this._channel.router!.rtpCapabilities, - stcConfig: this._createTransportConfig(this._stcTransport), - ctsConfig: this._createTransportConfig(this._ctsTransport), + stcConfig: this._createTransportConfig(this._stcTransport!), + ctsConfig: this._createTransportConfig(this._ctsTransport!), producerOptionsByKind: config.rtc.producerOptionsByKind } - })) as RtpCapabilities; + }); await Promise.all([ this._ctsTransport.setMaxIncomingBitrate(config.MAX_BITRATE_IN), this._stcTransport.setMaxOutgoingBitrate(config.MAX_BITRATE_OUT) @@ -553,7 +603,7 @@ export class Session extends EventEmitter { if (!producer) { return; } - logger.debug(`[${this.name}] ${type} ${active ? "on" : "off"}`); + logger.verbose(`[${this.name}] ${type} ${active ? "on" : "off"}`); if (active) { await producer.resume(); @@ -590,7 +640,10 @@ export class Session extends EventEmitter { } } - private async _handleRequest({ name, payload }: BusMessage): Promise { + private async _handleRequest({ + name, + payload + }: RequestMessage): Promise { switch (name) { case CLIENT_REQUEST.CONNECT_STC_TRANSPORT: { const { dtlsParameters } = payload; @@ -627,11 +680,40 @@ export class Session extends EventEmitter { this.info.isCameraOn = true; } const codec = producer.rtpParameters.codecs[0]; - logger.debug(`[${this.name}] producing ${type}: ${codec?.mimeType}`); + logger.verbose(`[${this.name}] producing ${type}: ${codec?.mimeType}`); this._updateRemoteConsumers(); this._broadcastInfo(); + /** + * @event Session#producer + * @type {{ type: StreamType, producer: Producer }} + */ + this.emit("producer", { type, producer }); return { id: producer.id }; } + case CLIENT_REQUEST.START_RECORDING: { + if (this.permissions.recording) { + return this._channel.recorder?.start(); + } + return; + } + case CLIENT_REQUEST.STOP_RECORDING: { + if (this.permissions.recording) { + return this._channel.recorder?.stop(); + } + return; + } + case CLIENT_REQUEST.START_TRANSCRIPTION: { + if (this.permissions.transcription) { + return this._channel.recorder?.startTranscription(); + } + return; + } + case CLIENT_REQUEST.STOP_TRANSCRIPTION: { + if (this.permissions.transcription) { + return this._channel.recorder?.stopTranscription(); + } + return; + } default: logger.warn(`[${this.name}] Unknown request type: ${name}`); throw new Error(`Unknown request type: ${name}`); diff --git a/src/server.ts b/src/server.ts index 80ab97d..7ef39d6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,4 @@ -import * as rtc from "#src/services/rtc.ts"; +import * as resources from "#src/services/resources.ts"; import * as http from "#src/services/http.ts"; import * as auth from "#src/services/auth.ts"; import { Logger } from "#src/utils/utils.ts"; @@ -8,15 +8,20 @@ const logger = new Logger("SERVER", { logLevel: "all" }); async function run(): Promise { auth.start(); - await rtc.start(); + await resources.start(); await http.start(); logger.info(`ready - PID: ${process.pid}`); + logger.debug(`TO IMPLEMENT: `); + logger.debug(`* get session labels from the odoo server`); + logger.debug(`* write tests for the recorder`); + logger.debug(`* use Promise.withResolvers() instead of Deferred`); + logger.debug(`* tests with mocked spawn`); } function cleanup(): void { Channel.closeAll(); http.close(); - rtc.close(); + resources.close(); logger.info("cleanup complete"); } diff --git a/src/services/auth.ts b/src/services/auth.ts index 4c4c5aa..9d4b0b8 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -3,7 +3,7 @@ import crypto from "node:crypto"; import * as config from "#src/config.ts"; import { Logger } from "#src/utils/utils.ts"; import { AuthenticationError } from "#src/utils/errors.ts"; -import type { SessionId } from "#src/models/session.ts"; +import type { SessionId, SessionPermissions } from "#src/models/session.ts"; import type { StringLike } from "#src/shared/types.ts"; /** @@ -43,6 +43,7 @@ interface PrivateJWTClaims { sfu_channel_uuid?: string; session_id?: SessionId; ice_servers?: object[]; + permissions?: SessionPermissions; sessionIdsByChannel?: Record; /** If provided when requesting a channel, this key will be used instead of the global key to verify JWTs related to this channel */ key?: string; diff --git a/src/services/http.ts b/src/services/http.ts index c00d9b5..bb20ef3 100644 --- a/src/services/http.ts +++ b/src/services/http.ts @@ -79,6 +79,29 @@ function setupRoutes(routeListener: RouteListener): void { return res.end(JSON.stringify(channelStats)); } }); + /** + * GET /v1/channel + * + * Creates (or reuses) a media channel for the authenticated client. + * + * ### Headers + * - `Authorization: Bearer ` — required. + * The JWT must include the `iss` (issuer) claim identifying the caller. + * + * ### Query Parameters + * - `webRTC` — optional, defaults to `"true"`. + * When set to `"false"`, disables WebRTC setup and creates a non-media channel. + * - `recordingAddress` — optional. + * If provided, enables recording and specifies the destination address + * for recorded media streams. This address should most likely include a secret token, + * so that it can be used publicly. For example http://example.com/recording/123?token=asdasdasdasd + * + * ### Responses + * - `200 OK` — returns `{ uuid: string, url: string }` + * - `401 Unauthorized` — missing or invalid Authorization header + * - `403 Forbidden` — missing `iss` claim + * - `500 Internal Server Error` — failed to create the channel + */ routeListener.get(`/v${API_VERSION}/channel`, { callback: async (req, res, { host, protocol, remoteAddress, searchParams }) => { try { @@ -98,7 +121,8 @@ function setupRoutes(routeListener: RouteListener): void { } const channel = await Channel.create(remoteAddress, claims.iss, { key: claims.key, - useWebRtc: searchParams.get("webRTC") !== "false" + useWebRtc: searchParams.get("webRTC") !== "false", + recordingAddress: searchParams.get("recordingAddress") }); res.setHeader("Content-Type", "application/json"); res.statusCode = 200; diff --git a/src/services/resources.ts b/src/services/resources.ts new file mode 100644 index 0000000..f189ad4 --- /dev/null +++ b/src/services/resources.ts @@ -0,0 +1,165 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import * as mediasoup from "mediasoup"; + +import * as config from "#src/config.ts"; +import { Logger } from "#src/utils/utils.ts"; +import { PortLimitReachedError } from "#src/utils/errors.ts"; + +const availablePorts: number[] = []; +let unique = 1; + +// TODO instead of RtcWorker, try Worker +export interface RtcWorker extends mediasoup.types.Worker { + appData: { + webRtcServer?: mediasoup.types.WebRtcServer; + }; +} + +// TODO maybe write some docstring, file used to manage resources such as folders, workers, ports + +const logger = new Logger("RESOURCES"); +const workers = new Set(); + +export async function start(): Promise { + logger.info("starting..."); + logger.info(`cleaning resources folder (${config.RESOURCES_PATH})...`); + await fs.rm(config.RESOURCES_PATH, { recursive: true }).catch((error) => { + logger.verbose(`Nothing to remove at ${config.RESOURCES_PATH}: ${error}`); + }); + for (let i = 0; i < config.NUM_WORKERS; ++i) { + await makeWorker(); + } + logger.info(`initialized ${workers.size} mediasoup workers`); + logger.info( + `transport(RTC) layer at ${config.PUBLIC_IP}:${config.RTC_MIN_PORT}-${config.RTC_MAX_PORT}` + ); + /** + * Moving ports in steps of 2 because FFMPEG may use their allocated port + 1 for RTCP + */ + for (let i = config.dynamicPorts.min; i <= config.dynamicPorts.max; i += 2) { + availablePorts.push(i); + } + logger.info( + `${availablePorts.length} dynamic ports available [${config.dynamicPorts.min}-${config.dynamicPorts.max}]` + ); +} + +export function close(): void { + for (const worker of workers) { + worker.appData.webRtcServer?.close(); + worker.close(); + } + for (const dir of Folder.usedDirs) { + fs.rm(dir, { recursive: true }).catch((error) => { + logger.error(`Failed to delete folder ${dir}: ${error}`); + }); + } + Folder.usedDirs.clear(); + workers.clear(); +} + +async function makeWorker(): Promise { + const worker = await mediasoup.createWorker(config.rtc.workerSettings); + worker.appData.webRtcServer = await worker.createWebRtcServer(config.rtc.rtcServerOptions); + workers.add(worker as RtcWorker); + worker.once("died", (error: Error) => { + logger.error(`worker died: ${error.message} ${error.stack ?? ""}`); + workers.delete(worker); + /** + * A new worker is made to replace the one that died. + * TODO: We may want to limit the amount of times this happens in case deaths are unrecoverable. + */ + makeWorker().catch((recoveryError) => { + logger.error(`Failed to create replacement worker: ${recoveryError.message}`); + }); + }); +} + +/** + * @throws {Error} If no workers are available + */ +export async function getWorker(): Promise { + const proms = []; + let leastUsedWorker: mediasoup.types.Worker | undefined; + let lowestUsage = Infinity; + for (const worker of workers) { + proms.push( + (async () => { + const { ru_maxrss } = await worker.getResourceUsage(); + if (ru_maxrss < lowestUsage) { + leastUsedWorker = worker; + lowestUsage = ru_maxrss; + } + })() + ); + } + await Promise.all(proms); + if (!leastUsedWorker) { + throw new Error("No mediasoup workers available"); + } + logger.verbose(`worker ${leastUsedWorker!.pid} with ${lowestUsage} ru_maxrss was selected`); + return leastUsedWorker; +} + +export class Folder { + static usedDirs: Set = new Set(); + path: string; + + static async create(name: string) { + const p: string = path.join(config.RESOURCES_PATH, name); + await fs.mkdir(p, { recursive: true }); + return new Folder(p); + } + + constructor(path: string) { + this.path = path; + Folder.usedDirs.add(path); + } + + async add(name: string, content: string) { + await fs.writeFile(path.join(this.path, name), content); + } + + async seal(path: string) { + const destinationPath = path; + try { + await fs.rename(this.path, destinationPath); + logger.verbose(`Moved folder from ${this.path} to ${destinationPath}`); + Folder.usedDirs.delete(this.path); + this.path = destinationPath; + } catch (error) { + logger.error(`Failed to move folder from ${this.path} to ${destinationPath}: ${error}`); + } + } + async delete() { + try { + await fs.rm(this.path, { recursive: true }); + logger.verbose(`Deleted folder ${this.path}`); + } catch (error) { + logger.error(`Failed to delete folder ${this.path}: ${error}`); + } + } +} + +export async function getFolder(): Promise { + return Folder.create(`${Date.now()}-${unique++}`); +} +export class DynamicPort { + number: number; + + constructor() { + const maybeNum = availablePorts.shift(); + if (!maybeNum) { + throw new PortLimitReachedError(); + } + this.number = maybeNum; + logger.verbose(`Acquired port ${this.number}`); + } + + release() { + availablePorts.push(this.number); + logger.verbose(`Released port ${this.number}`); + } +} diff --git a/src/services/rtc.ts b/src/services/rtc.ts deleted file mode 100644 index 2e546c7..0000000 --- a/src/services/rtc.ts +++ /dev/null @@ -1,78 +0,0 @@ -import * as mediasoup from "mediasoup"; -import type { WebRtcServerOptions } from "mediasoup/node/lib/types"; - -import * as config from "#src/config.ts"; -import { Logger } from "#src/utils/utils.ts"; - -export interface RtcWorker extends mediasoup.types.Worker { - appData: { - webRtcServer?: mediasoup.types.WebRtcServer; - }; -} - -const logger = new Logger("RTC"); -const workers = new Set(); - -export async function start(): Promise { - logger.info("starting..."); - for (let i = 0; i < config.NUM_WORKERS; ++i) { - await makeWorker(); - } - logger.info(`initialized ${workers.size} mediasoup workers`); - logger.info( - `transport(RTC) layer at ${config.PUBLIC_IP}:${config.RTC_MIN_PORT}-${config.RTC_MAX_PORT}` - ); -} - -export function close(): void { - for (const worker of workers) { - worker.appData.webRtcServer?.close(); - worker.close(); - } - workers.clear(); -} - -async function makeWorker(): Promise { - const worker = (await mediasoup.createWorker(config.rtc.workerSettings)) as RtcWorker; - worker.appData.webRtcServer = await worker.createWebRtcServer( - config.rtc.rtcServerOptions as WebRtcServerOptions - ); - workers.add(worker); - worker.once("died", (error: Error) => { - logger.error(`worker died: ${error.message} ${error.stack ?? ""}`); - workers.delete(worker); - /** - * A new worker is made to replace the one that died. - * TODO: We may want to limit the amount of times this happens in case deaths are unrecoverable. - */ - makeWorker().catch((recoveryError) => { - logger.error(`Failed to create replacement worker: ${recoveryError.message}`); - }); - }); -} - -/** - * @throws {Error} If no workers are available - */ -export async function getWorker(): Promise { - const proms = []; - let leastUsedWorker: mediasoup.types.Worker | undefined; - let lowestUsage = Infinity; - for (const worker of workers) { - proms.push( - (async () => { - const { ru_maxrss } = await worker.getResourceUsage(); - if (ru_maxrss < lowestUsage) { - leastUsedWorker = worker; - lowestUsage = ru_maxrss; - } - })() - ); - } - await Promise.all(proms); - if (!leastUsedWorker) { - throw new Error("No mediasoup workers available"); - } - logger.debug(`worker ${leastUsedWorker!.pid} with ${lowestUsage} ru_maxrss was selected`); - return leastUsedWorker; -} diff --git a/src/services/ws.ts b/src/services/ws.ts index 5ca1bda..620fca6 100644 --- a/src/services/ws.ts +++ b/src/services/ws.ts @@ -112,7 +112,7 @@ function connect(webSocket: WebSocket, credentials: Credentials): Session { const { channelUUID, jwt } = credentials; let channel = channelUUID ? Channel.records.get(channelUUID) : undefined; const authResult = verify(jwt, channel?.key); - const { sfu_channel_uuid, session_id } = authResult; + const { sfu_channel_uuid, session_id, permissions } = authResult; if (!channelUUID && sfu_channel_uuid) { // Cases where the channelUUID is not provided in the credentials for backwards compatibility with version 1.1 and earlier. channel = Channel.records.get(sfu_channel_uuid); @@ -128,9 +128,10 @@ function connect(webSocket: WebSocket, credentials: Credentials): Session { if (!session_id) { throw new AuthenticationError("Malformed JWT payload"); } - webSocket.send(""); // client can start using ws after this message. const bus = new Bus(webSocket, { batchDelay: config.timeouts.busBatch }); const { session } = Channel.join(channel.uuid, session_id); + session.updatePermissions(permissions); + webSocket.send(JSON.stringify(session.startupData)); // client can start using ws after this message. session.once("close", ({ code }: { code: string }) => { let wsCloseCode = WS_CLOSE_CODE.CLEAN; switch (code) { diff --git a/src/shared/bus.ts b/src/shared/bus.ts index 5171485..10ca2c6 100644 --- a/src/shared/bus.ts +++ b/src/shared/bus.ts @@ -1,9 +1,16 @@ import type { WebSocket as NodeWebSocket } from "ws"; -import type { JSONSerializable, BusMessage } from "./types"; +import type { + JSONSerializable, + BusMessage, + RequestMessage, + RequestName, + ResponseFrom +} from "./types"; + export interface Payload { /** The actual message content */ - message: BusMessage; + message: BusMessage | JSONSerializable; /** Request ID if this message expects a response */ needResponse?: string; /** Response ID if this message is responding to a request */ @@ -46,11 +53,9 @@ export class Bus { /** Global ID counter for Bus instances */ private static _idCount = 0; /** Message handler for incoming messages */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any public onMessage?: (message: BusMessage) => void; /** Request handler for incoming requests */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public onRequest?: (request: BusMessage) => Promise; + public onRequest?: (request: RequestMessage) => Promise; /** Unique bus instance identifier */ public readonly id: number = Bus._idCount++; /** Request counter for generating unique request IDs */ @@ -96,8 +101,10 @@ export class Bus { /** * Sends a request and waits for a response */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - request(message: BusMessage, options: RequestOptions = {}): Promise { + request( + message: RequestMessage, + options: RequestOptions = {} + ): Promise> { const { timeout = 5000, batch } = options; const requestId = this._getNextRequestId(); return new Promise((resolve, reject) => { @@ -105,7 +112,11 @@ export class Bus { reject(new Error("bus request timed out")); this._pendingRequests.delete(requestId); }, timeout); - this._pendingRequests.set(requestId, { resolve, reject, timeout: timeoutId }); + this._pendingRequests.set(requestId, { + resolve, + reject, + timeout: timeoutId + }); this._sendPayload(message, { needResponse: requestId, batch }); }); } @@ -138,8 +149,7 @@ export class Bus { } private _sendPayload( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - message: BusMessage, + message: BusMessage | JSONSerializable, options: { needResponse?: string; responseTo?: string; @@ -212,11 +222,11 @@ export class Bus { } } else if (needResponse) { // This is a request that expects a response - const response = await this.onRequest?.(message); - this._sendPayload(response!, { responseTo: needResponse }); + const response = await this.onRequest?.(message as RequestMessage); + this._sendPayload(response ?? {}, { responseTo: needResponse }); } else { // This is a plain message - this.onMessage?.(message); + this.onMessage?.(message as BusMessage); } } } diff --git a/src/shared/enums.ts b/src/shared/enums.ts index a8703d6..1edd9fc 100644 --- a/src/shared/enums.ts +++ b/src/shared/enums.ts @@ -24,7 +24,9 @@ export enum SERVER_MESSAGE { /** Signals the clients that one of the session in their channel has left */ SESSION_LEAVE = "SESSION_LEAVE", /** Signals the clients that the info (talking, mute,...) of one of the session in their channel has changed */ - INFO_CHANGE = "S_INFO_CHANGE" + INFO_CHANGE = "S_INFO_CHANGE", + /** Signals the clients that the info of the channel (isRecording,...) has changed */ + CHANNEL_INFO_CHANGE = "C_INFO_CHANGE" } export enum CLIENT_REQUEST { @@ -33,7 +35,13 @@ export enum CLIENT_REQUEST { /** Requests the server to connect the server-to-client transport */ CONNECT_STC_TRANSPORT = "CONNECT_STC_TRANSPORT", /** Requests the creation of a consumer that is used to upload a track to the server */ - INIT_PRODUCER = "INIT_PRODUCER" + INIT_PRODUCER = "INIT_PRODUCER", + /** Requests to start recording of the call */ + START_RECORDING = "START_RECORDING", + /** Requests to stop recording of the call */ + STOP_RECORDING = "STOP_RECORDING", + START_TRANSCRIPTION = "START_TRANSCRIPTION", + STOP_TRANSCRIPTION = "STOP_TRANSCRIPTION" } export enum CLIENT_MESSAGE { diff --git a/src/shared/types.ts b/src/shared/types.ts index 4210662..fd67eec 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -10,6 +10,17 @@ export type StreamType = "audio" | "camera" | "screen"; export type StringLike = Buffer | string; +export type StartupData = { + availableFeatures: AvailableFeatures; + isRecording: boolean; + isTranscribing: boolean; +}; +export type AvailableFeatures = { + rtc: boolean; + recording: boolean; + transcription: boolean; +}; + import type { DownloadStates } from "#src/client.ts"; import type { SessionId, SessionInfo, TransportConfig } from "#src/models/session.ts"; @@ -21,7 +32,7 @@ import type { RtpParameters // eslint-disable-next-line node/no-unpublished-import } from "mediasoup-client/lib/types"; -import type { CLIENT_MESSAGE, CLIENT_REQUEST, SERVER_MESSAGE, SERVER_REQUEST } from "./enums.ts"; +import type { CLIENT_MESSAGE, CLIENT_REQUEST, SERVER_MESSAGE, SERVER_REQUEST } from "./enums"; export type BusMessage = | { name: typeof CLIENT_MESSAGE.BROADCAST; payload: JSONSerializable } @@ -49,12 +60,20 @@ export type BusMessage = name: typeof CLIENT_REQUEST.INIT_PRODUCER; payload: { type: StreamType; kind: MediaKind; rtpParameters: RtpParameters }; } + | { name: typeof CLIENT_REQUEST.START_RECORDING; payload?: never } + | { name: typeof CLIENT_REQUEST.STOP_RECORDING; payload?: never } + | { name: typeof CLIENT_REQUEST.START_TRANSCRIPTION; payload?: never } + | { name: typeof CLIENT_REQUEST.STOP_TRANSCRIPTION; payload?: never } | { name: typeof SERVER_MESSAGE.BROADCAST; payload: { senderId: SessionId; message: JSONSerializable }; } | { name: typeof SERVER_MESSAGE.SESSION_LEAVE; payload: { sessionId: SessionId } } | { name: typeof SERVER_MESSAGE.INFO_CHANGE; payload: Record } + | { + name: typeof SERVER_MESSAGE.CHANNEL_INFO_CHANGE; + payload: { isRecording: boolean; isTranscribing: boolean }; + } | { name: typeof SERVER_REQUEST.INIT_CONSUMER; payload: { @@ -77,3 +96,22 @@ export type BusMessage = }; } | { name: typeof SERVER_REQUEST.PING; payload?: never }; + +export interface RequestMap { + [CLIENT_REQUEST.CONNECT_CTS_TRANSPORT]: void; + [CLIENT_REQUEST.CONNECT_STC_TRANSPORT]: void; + [CLIENT_REQUEST.INIT_PRODUCER]: { id: string }; + [CLIENT_REQUEST.START_RECORDING]: boolean; + [CLIENT_REQUEST.STOP_RECORDING]: boolean; + [CLIENT_REQUEST.START_TRANSCRIPTION]: boolean; + [CLIENT_REQUEST.STOP_TRANSCRIPTION]: boolean; + [SERVER_REQUEST.INIT_CONSUMER]: void; + [SERVER_REQUEST.INIT_TRANSPORTS]: RtpCapabilities; + [SERVER_REQUEST.PING]: void; +} + +export type RequestName = keyof RequestMap; + +export type RequestMessage = Extract; + +export type ResponseFrom = RequestMap[T]; diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 5eb7855..62ee4f9 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -5,3 +5,7 @@ export class AuthenticationError extends Error { export class OvercrowdedError extends Error { name = "OvercrowdedError"; } + +export class PortLimitReachedError extends Error { + name = "PortLimitReachedError"; +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 39773cb..46e654c 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -12,7 +12,9 @@ const ASCII = { green: "\x1b[32m", yellow: "\x1b[33m", white: "\x1b[37m", - default: "\x1b[0m" + cyan: "\x1b[36m", + default: "\x1b[0m", + pink: "\x1b[35m" } } as const; @@ -48,6 +50,49 @@ export interface ParseBodyOptions { json?: boolean; } +export class Deferred { + private readonly _promise: Promise; + public resolve!: (value: T | PromiseLike) => void; + public reject!: (reason?: unknown) => void; + + constructor() { + this._promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } + + public then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): Promise { + return this._promise.then(onfulfilled, onrejected); + } + + public catch( + onrejected?: ((reason: unknown) => TResult | PromiseLike) | null + ): Promise { + return this._promise.catch(onrejected); + } + + public finally(onfinally?: (() => void) | null): Promise { + return this._promise.finally(onfinally); + } +} + +function getCallChain(depth: number = 8): string { + const stack = new Error().stack?.split("\n").slice(2, depth + 2) ?? []; + return stack + .map((line) => { + const match = line.trim().match(/^at\s+(.*?)\s+\(/); + return match ? match[1] : null; + }) + .slice(1, depth + 1) + .filter(Boolean) + .reverse() + .join(" > "); +} + export class Logger { private readonly _name: string; private readonly _colorize: (text: string, color?: string) => string; @@ -78,11 +123,14 @@ export class Logger { this._log(console.log, ":INFO:", text, ASCII.color.green); } debug(text: string): void { - this._log(console.log, ":DEBUG:", text); + this._log(console.log, ":DEBUG:", text, ASCII.color.pink); } verbose(text: string): void { this._log(console.log, ":VERBOSE:", text, ASCII.color.white); } + trace(message: string, { depth = 8 }: { depth?: number } = {}): void { + this._log(console.log, ":TRACE:", `${getCallChain(depth)} ${message}`, ASCII.color.cyan); + } private _generateTimeStamp(): string { const now = new Date(); return now.toISOString() + " "; diff --git a/tests/bus.test.ts b/tests/bus.test.ts index 2d980cc..0acd0d6 100644 --- a/tests/bus.test.ts +++ b/tests/bus.test.ts @@ -3,7 +3,7 @@ import { EventEmitter } from "node:events"; import { expect, describe, jest } from "@jest/globals"; import { Bus } from "#src/shared/bus"; -import type { JSONSerializable, BusMessage } from "#src/shared/types"; +import type { JSONSerializable, BusMessage, RequestMessage } from "#src/shared/types"; class MockTargetWebSocket extends EventTarget { send(message: JSONSerializable) { @@ -74,14 +74,14 @@ describe("Bus API", () => { return "pong"; } }; - const response = await aliceBus.request("ping" as unknown as BusMessage); + const response = await aliceBus.request("ping" as unknown as RequestMessage); expect(response).toBe("pong"); }); test("promises are rejected when the bus is closed", async () => { const { aliceSocket } = mockSocketPair(); const aliceBus = new Bus(aliceSocket as unknown as WebSocket); let rejected = false; - const promise = aliceBus.request("ping" as unknown as BusMessage); + const promise = aliceBus.request("ping" as unknown as RequestMessage); aliceBus.close(); try { await promise; @@ -96,7 +96,7 @@ describe("Bus API", () => { const { aliceSocket } = mockSocketPair(); const aliceBus = new Bus(aliceSocket as unknown as WebSocket); const timeout = 500; - const promise = aliceBus.request("hello" as unknown as BusMessage, { timeout }); + const promise = aliceBus.request("hello" as unknown as RequestMessage, { timeout }); jest.advanceTimersByTime(timeout); await expect(promise).rejects.toThrow(); jest.useRealTimers(); diff --git a/tests/models.test.ts b/tests/models.test.ts index d84f49b..0b00d9d 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -1,17 +1,17 @@ import { describe, beforeEach, afterEach, expect, jest } from "@jest/globals"; -import * as rtc from "#src/services/rtc"; +import * as resources from "#src/services/resources"; import { Channel } from "#src/models/channel"; import { timeouts, CHANNEL_SIZE } from "#src/config"; import { OvercrowdedError } from "#src/utils/errors"; describe("Models", () => { beforeEach(async () => { - await rtc.start(); + await resources.start(); }); afterEach(() => { Channel.closeAll(); - rtc.close(); + resources.close(); }); test("Create channel and session", async () => { const channel = await Channel.create("testRemote", "testIssuer"); diff --git a/tests/network.test.ts b/tests/network.test.ts index 6a90558..a127247 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -11,6 +11,7 @@ import { timeouts } from "#src/config"; import { LocalNetwork } from "#tests/utils/network"; import { delay } from "#tests/utils/utils.ts"; +import { RECORDER_STATE } from "#src/models/recorder.ts"; const HTTP_INTERFACE = "0.0.0.0"; const PORT = 61254; @@ -141,11 +142,11 @@ describe("Full network", () => { test("A client can forward a track to other clients", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1); - await once(user1.session, "stateChange"); + await user1.isConnected; const user2 = await network.connect(channelUUID, 2); - await once(user2.session, "stateChange"); + await user2.isConnected; const sender = await network.connect(channelUUID, 3); - await once(sender.session, "stateChange"); + await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "audio" }); await sender.sfuClient.updateUpload(STREAM_TYPE.AUDIO, track); const prom1 = once(user1.sfuClient, "update"); @@ -161,9 +162,9 @@ describe("Full network", () => { test("Recovery attempts are made if the production fails, a failure does not close the connection", async () => { const channelUUID = await network.getChannelUUID(); const user = await network.connect(channelUUID, 1); - await once(user.session, "stateChange"); + await user.isConnected; const sender = await network.connect(channelUUID, 3); - await once(sender.session, "stateChange"); + await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "audio" }); // closing the transport so the `updateUpload` should fail. // @ts-expect-error accessing private property for testing purposes @@ -175,9 +176,9 @@ describe("Full network", () => { test("Recovery attempts are made if the consumption fails, a failure does not close the connection", async () => { const channelUUID = await network.getChannelUUID(); const user = await network.connect(channelUUID, 1); - await once(user.session, "stateChange"); + await user.isConnected; const sender = await network.connect(channelUUID, 3); - await once(sender.session, "stateChange"); + await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "audio" }); // closing the transport so the consumption should fail. // @ts-expect-error accessing private property for testing purposes @@ -191,9 +192,9 @@ describe("Full network", () => { test("The client can obtain download and upload statistics", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1); - await once(user1.session, "stateChange"); + await user1.isConnected; const sender = await network.connect(channelUUID, 3); - await once(sender.session, "stateChange"); + await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "audio" }); await sender.sfuClient.updateUpload(STREAM_TYPE.AUDIO, track); await once(user1.sfuClient, "update"); @@ -206,9 +207,9 @@ describe("Full network", () => { test("The client can update the state of their downloads", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1234); - await once(user1.session, "stateChange"); + await user1.isConnected; const sender = await network.connect(channelUUID, 123); - await once(sender.session, "stateChange"); + await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "audio" }); await sender.sfuClient.updateUpload(STREAM_TYPE.AUDIO, track); await once(user1.sfuClient, "update"); @@ -228,9 +229,9 @@ describe("Full network", () => { test("The client can update the state of their upload", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1234); - await once(user1.session, "stateChange"); + await user1.isConnected; const sender = await network.connect(channelUUID, 123); - await once(sender.session, "stateChange"); + await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "video" }); await sender.sfuClient.updateUpload(STREAM_TYPE.CAMERA, track); await once(user1.sfuClient, "update"); @@ -284,4 +285,51 @@ describe("Full network", () => { expect(event1.detail.payload.message).toBe(message); expect(event2.detail.payload.message).toBe(message); }); + test("POC RECORDING", async () => { + const channelUUID = await network.getChannelUUID(); + const user1 = await network.connect(channelUUID, 1); + await user1.isConnected; + const user2 = await network.connect(channelUUID, 3); + await user2.isConnected; + expect(user2.sfuClient.availableFeatures.recording).toBe(true); + const startResult = (await user2.sfuClient.startRecording()) as boolean; + expect(startResult).toBe(true); + const stopResult = (await user2.sfuClient.stopRecording()) as boolean; + expect(stopResult).toBe(false); + }); + test("POC TRANSCRIPTION", async () => { + const channelUUID = await network.getChannelUUID(); + const user1 = await network.connect(channelUUID, 1); + await user1.isConnected; + const user2 = await network.connect(channelUUID, 3); + await user2.isConnected; + expect(user2.sfuClient.availableFeatures.transcription).toBe(true); + const startResult = (await user2.sfuClient.startTranscription()) as boolean; + expect(startResult).toBe(true); + const stopResult = (await user2.sfuClient.stopTranscription()) as boolean; + expect(stopResult).toBe(false); + }); + test("POC COMBINED TRANSCRIPTION/RECORDING", async () => { + const channelUUID = await network.getChannelUUID(); + const channel = Channel.records.get(channelUUID); + const user1 = await network.connect(channelUUID, 1); + await user1.isConnected; + const user2 = await network.connect(channelUUID, 3); + await user2.isConnected; + await user2.sfuClient.startTranscription(); + await user1.sfuClient.startRecording(); + const recorder = channel!.recorder!; + expect(recorder.isRecording).toBe(true); + expect(recorder.isTranscribing).toBe(true); + expect(recorder.state).toBe(RECORDER_STATE.STARTED); + await user1.sfuClient.stopRecording(); + // stopping the recording while a transcription is active should not stop the transcription + expect(recorder.isRecording).toBe(false); + expect(recorder.isTranscribing).toBe(true); + expect(recorder.state).toBe(RECORDER_STATE.STARTED); + await user2.sfuClient.stopTranscription(); + expect(recorder.isRecording).toBe(false); + expect(recorder.isTranscribing).toBe(false); + expect(recorder.state).toBe(RECORDER_STATE.STOPPED); + }); }); diff --git a/tests/rtc.test.ts b/tests/rtc.test.ts index 08c188d..37caa6f 100644 --- a/tests/rtc.test.ts +++ b/tests/rtc.test.ts @@ -1,19 +1,19 @@ import { afterEach, beforeEach, describe, expect } from "@jest/globals"; -import * as rtc from "#src/services/rtc"; +import * as resources from "#src/services/resources"; import * as config from "#src/config"; describe("rtc service", () => { beforeEach(async () => { - await rtc.start(); + await resources.start(); }); afterEach(() => { - rtc.close(); + resources.close(); }); test("worker load should be evenly distributed", async () => { const usedWorkers = new Set(); for (let i = 0; i < config.NUM_WORKERS; ++i) { - const worker = await rtc.getWorker(); + const worker = await resources.getWorker(); const router = await worker.createRouter({}); const webRtcServer = await worker.createWebRtcServer(config.rtc.rtcServerOptions); const promises = []; diff --git a/tests/utils/network.ts b/tests/utils/network.ts index dace247..8597d4b 100644 --- a/tests/utils/network.ts +++ b/tests/utils/network.ts @@ -5,7 +5,8 @@ import * as fakeParameters from "mediasoup-client/lib/test/fakeParameters"; import * as auth from "#src/services/auth"; import * as http from "#src/services/http"; -import * as rtc from "#src/services/rtc"; +import * as resources from "#src/services/resources"; +import { Deferred } from "#src/utils/utils"; import { SfuClient, SfuClientState } from "#src/client"; import { Channel } from "#src/models/channel"; import type { Session } from "#src/models/session"; @@ -37,6 +38,8 @@ interface ConnectionResult { session: Session; /** Client-side SFU client instance */ sfuClient: SfuClient; + /** Promise resolving to true when client is connected */ + isConnected: Deferred; } /** @@ -69,9 +72,9 @@ export class LocalNetwork { this.port = port; // Start all services in correct order - await rtc.start(); + await resources.start(); await http.start({ httpInterface: hostname, port }); - await auth.start(HMAC_B64_KEY); + auth.start(HMAC_B64_KEY); } /** @@ -90,9 +93,9 @@ export class LocalNetwork { iss: `http://${this.hostname}:${this.port}/`, key }); - + const TEST_RECORDING_ADDRESS = "http://localhost:8080"; // TODO maybe to change and use that later const response = await fetch( - `http://${this.hostname}:${this.port}/v${http.API_VERSION}/channel?webRTC=${useWebRtc}`, + `http://${this.hostname}:${this.port}/v${http.API_VERSION}/channel?webRTC=${useWebRtc}&recordingAddress=${TEST_RECORDING_ADDRESS}`, { method: "GET", headers: { @@ -147,29 +150,37 @@ export class LocalNetwork { }; // Set up authentication promise - const isClientAuthenticated = new Promise((resolve, reject) => { - const handleStateChange = (event: CustomEvent) => { - const { state } = event.detail; - switch (state) { - case SfuClientState.AUTHENTICATED: - sfuClient.removeEventListener( - "stateChange", - handleStateChange as EventListener - ); - resolve(true); - break; - case SfuClientState.CLOSED: - sfuClient.removeEventListener( - "stateChange", - handleStateChange as EventListener - ); - reject(new Error("client closed")); - break; - } - }; - - sfuClient.addEventListener("stateChange", handleStateChange as EventListener); - }); + const isClientAuthenticated = new Deferred(); + const handleStateChange = (event: CustomEvent) => { + const { state } = event.detail; + switch (state) { + case SfuClientState.AUTHENTICATED: + sfuClient.removeEventListener( + "stateChange", + handleStateChange as EventListener + ); + isClientAuthenticated.resolve(true); + break; + case SfuClientState.CLOSED: + sfuClient.removeEventListener( + "stateChange", + handleStateChange as EventListener + ); + isClientAuthenticated.reject(new Error("client closed")); + break; + } + }; + sfuClient.addEventListener("stateChange", handleStateChange as EventListener); + + const isConnected = new Deferred(); + const connectedHandler = (event: CustomEvent) => { + const { state } = event.detail; + if (state === SfuClientState.CONNECTED) { + sfuClient.removeEventListener("stateChange", connectedHandler as EventListener); + isConnected.resolve(true); + } + }; + sfuClient.addEventListener("stateChange", connectedHandler as EventListener); // Start connection sfuClient.connect( @@ -177,7 +188,11 @@ export class LocalNetwork { this.makeJwt( { sfu_channel_uuid: channelUUID, - session_id: sessionId + session_id: sessionId, + permissions: { + recording: true, + transcription: true + } }, key ), @@ -198,7 +213,7 @@ export class LocalNetwork { throw new Error(`Session ${sessionId} not found in channel ${channelUUID}`); } - return { session, sfuClient }; + return { session, sfuClient, isConnected }; } /** @@ -217,7 +232,7 @@ export class LocalNetwork { // Stop all services auth.close(); http.close(); - rtc.close(); + resources.close(); // Clear network info this.hostname = undefined; diff --git a/tsconfig.json b/tsconfig.json index 9140ec1..22e8df5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,16 +7,22 @@ "compilerOptions": { // Custom "target": "ES2022", - "lib": ["ES2022", "DOM"], + "lib": [ + "ES2022", + "DOM" + ], "module": "ESNext", "allowImportingTsExtensions": true, "noEmit": true, "baseUrl": ".", "paths": { - "#src/*": ["./src/*"], - "#tests/*": ["./tests/*"] + "#src/*": [ + "./src/*" + ], + "#tests/*": [ + "./tests/*" + ] }, - "outDir": "dist", "strict": true, "forceConsistentCasingInFileNames": true, "noUnusedLocals": true, @@ -30,8 +36,6 @@ ], "esModuleInterop": true, "resolveJsonModule": true, - "declaration": true, - "declarationDir": "dist/types", "preserveConstEnums": false, } -} +} \ No newline at end of file diff --git a/tsconfig_bundle.json b/tsconfig_bundle.json index aca1cdc..ba7b048 100644 --- a/tsconfig_bundle.json +++ b/tsconfig_bundle.json @@ -5,8 +5,8 @@ "module": "es6", "declaration": false, "sourceMap": false, - "noEmit": false, - "allowImportingTsExtensions": false + "allowImportingTsExtensions": true, + "noEmit": true }, "include": ["src/client.ts", "src/shared/**/*"], "exclude": ["tests/**/*", "src/server.ts", "src/services/**/*", "src/models/**/*"]