diff --git a/package-lock.json b/package-lock.json index 3708f2cb..7772e39e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,11 +18,11 @@ "lucide-react": "^0.561.0", "nitro": "^3.0.260429-beta", "nitro-nightly": "^3.0.1-20260501-164602-aee73f19", - "node-datachannel": "^0.32.3", "qrcode": "^1.5.4", "react": "^19.2.5", "react-dom": "^19.2.5", "react-icons": "^5.5.0", + "werift": "^0.23.0", "winston": "^3.19.0", "ws": "^8.18.0" }, @@ -359,7 +359,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1593,6 +1592,34 @@ } } }, + "node_modules/@fidm/asn1": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz", + "integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@fidm/x509": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@fidm/x509/-/x509-1.2.1.tgz", + "integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==", + "license": "MIT", + "dependencies": { + "@fidm/asn1": "^1.0.4", + "tweetnacl": "^1.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@fidm/x509/node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -1705,6 +1732,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -1760,6 +1793,12 @@ "node": ">=10" } }, + "node_modules/@minhducsun2002/leb128": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@minhducsun2002/leb128/-/leb128-1.0.0.tgz", + "integrity": "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1778,6 +1817,33 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", @@ -2545,11 +2611,101 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, "node_modules/@peculiar/asn1-schema": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "dev": true, "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", @@ -2557,6 +2713,30 @@ "tslib": "^2.8.1" } }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, "node_modules/@peculiar/json-schema": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", @@ -2574,7 +2754,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -2597,6 +2776,28 @@ "node": ">=14.18.0" } }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", @@ -3259,6 +3460,24 @@ "win32" ] }, + "node_modules/@shinyoshiaki/binary-data": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@shinyoshiaki/binary-data/-/binary-data-0.6.1.tgz", + "integrity": "sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==", + "license": "MIT", + "dependencies": { + "generate-function": "^2.3.1", + "is-plain-object": "^2.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@shinyoshiaki/jspack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@shinyoshiaki/jspack/-/jspack-0.0.6.tgz", + "integrity": "sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg==" + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -4847,6 +5066,12 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/aes-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", + "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==", + "license": "MIT" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -5150,7 +5375,6 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", @@ -5402,17 +5626,6 @@ "file-uri-to-path": "1.0.0" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -5476,9 +5689,9 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -5496,7 +5709,16 @@ "license": "MIT", "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" } }, "node_modules/buffer-from": { @@ -6237,6 +6459,22 @@ "node": ">=20" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", @@ -6355,6 +6593,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -6376,15 +6615,6 @@ "node": ">=6" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -6547,6 +6777,18 @@ "js-yaml": "^4.1.0" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -6981,6 +7223,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -7243,15 +7486,6 @@ "node": ">=18.0.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -7296,7 +7530,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -7486,12 +7719,6 @@ "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", "license": "MIT" }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -7640,6 +7867,15 @@ "node": ">=0.10.0" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -7723,12 +7959,6 @@ "assert-plus": "^1.0.0" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -8237,11 +8467,17 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" + "node_modules/int64-buffer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.1.0.tgz", + "integrity": "sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==", + "license": "MIT" + }, + "node_modules/ip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", + "license": "MIT" }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", @@ -8252,6 +8488,18 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -8259,6 +8507,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -8317,6 +8571,15 @@ "node": ">=18" } }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -8852,7 +9115,6 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, "license": "MIT" }, "node_modules/logform": { @@ -9008,6 +9270,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -9036,6 +9299,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9078,11 +9342,11 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" + "node_modules/mp4box": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-0.5.4.tgz", + "integrity": "sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==", + "license": "BSD-3-Clause" }, "node_modules/ms": { "version": "2.1.3", @@ -9090,6 +9354,19 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, "node_modules/nan": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", @@ -9116,12 +9393,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/nf3": { "version": "0.3.17", "resolved": "https://registry.npmjs.org/nf3/-/nf3-0.3.17.tgz", @@ -9240,16 +9511,6 @@ } } }, - "node_modules/nitro-nightly/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "extraneous": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/nitro-nightly/node_modules/srvx": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.10.1.tgz", @@ -9364,16 +9625,6 @@ } } }, - "node_modules/nitro/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "extraneous": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/nitro/node_modules/unstorage": { "version": "2.0.0-alpha.7", "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-2.0.0-alpha.7.tgz", @@ -9525,19 +9776,6 @@ "node": ">=10" } }, - "node_modules/node-datachannel": { - "version": "0.32.3", - "resolved": "https://registry.npmjs.org/node-datachannel/-/node-datachannel-0.32.3.tgz", - "integrity": "sha512-Aok1ZhLsll472lRefgWYuWJ0070jh0ecHravTdRyZEmoESumebMEQV8Y+poBwSW2ZbEwAokAOGsK5Cu8pDDT2g==", - "hasInstallScript": true, - "license": "MPL 2.0", - "dependencies": { - "prebuild-install": "^7.1.3" - }, - "engines": { - "node": ">=18.20.0" - } - }, "node_modules/node-gyp": { "version": "12.4.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", @@ -9747,6 +9985,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -9833,7 +10072,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10136,57 +10374,6 @@ "node": "^12.20.0 || >=14" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prebuild-install/node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prebuild-install/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prettier": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.1.tgz", @@ -10297,6 +10484,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -10317,7 +10505,6 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -10327,7 +10514,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", - "dev": true, "license": "MIT", "engines": { "node": ">=16.0.0" @@ -10472,21 +10658,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -10574,6 +10745,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/request": { "version": "2.88.2", "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", @@ -10833,6 +11010,11 @@ "integrity": "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==", "license": "MIT" }, + "node_modules/rx.mini": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/rx.mini/-/rx.mini-1.4.0.tgz", + "integrity": "sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA==" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -11028,51 +11210,6 @@ "devOptional": true, "license": "ISC" }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -11286,15 +11423,6 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -11389,40 +11517,6 @@ "node": ">=18" } }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -11471,6 +11565,12 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "license": "MIT" }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -11672,7 +11772,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, "license": "0BSD" }, "node_modules/tsx": { @@ -11694,11 +11793,30 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", + "optional": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -12618,6 +12736,143 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "node_modules/werift": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/werift/-/werift-0.23.0.tgz", + "integrity": "sha512-/WcIN5DHFG9Ri4anGOmIkp8gxBGFMWSIB/m4sfZ5CWlLfD3iMhiaAUuTBuc+KV3SY9NDmvmLtiN2uaM7k3lVzw==", + "license": "MIT", + "dependencies": { + "@fidm/x509": "^1.2.1", + "@minhducsun2002/leb128": "^1.0.0", + "@noble/curves": "^1.8.1", + "@peculiar/x509": "^1.12.3", + "@shinyoshiaki/binary-data": "^0.6.1", + "@shinyoshiaki/jspack": "^0.0.6", + "aes-js": "^3.1.2", + "buffer": "^6.0.3", + "debug": "4.4.0", + "fast-deep-equal": "^3.1.3", + "int64-buffer": "1.1.0", + "ip": "^2.0.1", + "mp4box": "^0.5.3", + "multicast-dns": "^7.2.5", + "tweetnacl": "^1.0.3", + "werift-common": "*", + "werift-dtls": "*", + "werift-ice": "*", + "werift-rtp": "*", + "werift-sctp": "*" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/werift-common": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/werift-common/-/werift-common-0.0.3.tgz", + "integrity": "sha512-ma3E4BqKTyZVLhrdfTVs2T1tg9seeUtKMRn5e64LwgrogWa62+3LAUoLBUSl1yPWhgSkXId7GmcHuWDen9IJeQ==", + "license": "MIT", + "dependencies": { + "@shinyoshiaki/jspack": "^0.0.6", + "debug": "^4.4.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/werift-dtls": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/werift-dtls/-/werift-dtls-0.5.7.tgz", + "integrity": "sha512-z2fjbP7fFUFmu/Ky4bCKXzdgPTtmSY1DYi0TUf3GG2zJT4jMQ3TQmGY8y7BSSNGetvL4h3pRZ5un0EcSOWpPog==", + "license": "MIT", + "dependencies": { + "@fidm/x509": "^1.2.1", + "@noble/curves": "^1.3.0", + "@peculiar/x509": "^1.9.2", + "@shinyoshiaki/binary-data": "^0.6.1", + "date-fns": "^2.29.3", + "lodash": "^4.17.21", + "rx.mini": "^1.2.2", + "tweetnacl": "^1.0.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/werift-dtls/node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/werift-ice": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/werift-ice/-/werift-ice-0.2.2.tgz", + "integrity": "sha512-td52pHp+JmFnUn5jfDr/SSNO0dMCbknhuPdN1tFp9cfRj5jaktN63qnAdUuZC20QCC3ETWdsOthcm+RalHpFCQ==", + "license": "MIT", + "dependencies": { + "@shinyoshiaki/jspack": "^0.0.6", + "buffer-crc32": "^1.0.0", + "debug": "^4.3.4", + "int64-buffer": "^1.0.1", + "ip": "^2.0.1", + "lodash": "^4.17.21", + "multicast-dns": "^7.2.5", + "p-cancelable": "^2.1.1", + "rx.mini": "^1.2.2" + } + }, + "node_modules/werift-rtp": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/werift-rtp/-/werift-rtp-0.8.8.tgz", + "integrity": "sha512-GiYMSdvCyScQaw5bnEsraSoHUVZpjfokJAiLV4R1FsiB06t6XiebPYPpkqB9nYNNKiA8Z/cYWsym7wISq1sYSQ==", + "license": "MIT", + "dependencies": { + "@minhducsun2002/leb128": "^1.0.0", + "@shinyoshiaki/jspack": "^0.0.6", + "aes-js": "^3.1.2", + "buffer": "^6.0.3", + "mp4box": "^0.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/werift-sctp": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/werift-sctp/-/werift-sctp-0.0.11.tgz", + "integrity": "sha512-7109yuI5U7NTEHjqjn0A8VeynytkgVaxM6lRr1Ziv0D8bPcaB8A7U/P88M7WaCpWDoELHoXiRUjQycMWStIgjQ==", + "license": "MIT", + "dependencies": { + "@shinyoshiaki/jspack": "^0.0.6" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/werift/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/werift/node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -12785,6 +13040,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, "license": "ISC" }, "node_modules/ws": { @@ -12818,30 +13074,6 @@ "node": ">=18" } }, - "node_modules/xml2js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xml2js/node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 22a7ff3f..35d498cf 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,11 @@ "lucide-react": "^0.561.0", "nitro": "^3.0.260429-beta", "nitro-nightly": "^3.0.1-20260501-164602-aee73f19", - "node-datachannel": "^0.32.3", "qrcode": "^1.5.4", "react": "^19.2.5", "react-dom": "^19.2.5", "react-icons": "^5.5.0", + "werift": "^0.23.0", "winston": "^3.19.0", "ws": "^8.18.0" }, @@ -102,7 +102,6 @@ "electron@40.10.5": true, "electron-winstaller@5.4.0": true, "esbuild@0.28.1": true, - "koffi@2.16.2": true, - "node-datachannel@0.32.3": true + "koffi@2.16.2": true } } diff --git a/src/hooks/useWebRtcStream.ts b/src/hooks/useWebRtcStream.ts index bef91582..9888ca5b 100644 --- a/src/hooks/useWebRtcStream.ts +++ b/src/hooks/useWebRtcStream.ts @@ -11,18 +11,17 @@ const MAX_RETRIES = 5 export function useWebRtcStream({ token }: UseWebRtcStreamOptions) { const [trackActive, setTrackActive] = useState(false) const [videoStream, setVideoStream] = useState(null) - const [activeSessionId, setActiveSessionId] = useState(null) const [error, setError] = useState(null) const [errorHandle, setErrorHandle] = useState(null) const [reconnectAttempt, setReconnectAttempt] = useState(0) const { registerDataChannel, send: sendInputEvent } = useConnection() - const videoPcRef = useRef(null) - const inputPcRef = useRef(null) - const sseSourceRef = useRef(null) + const pcRef = useRef(null) + const wsRef = useRef(null) const retryTimerRef = useRef | null>(null) const trackActiveRef = useRef(false) const retryCountRef = useRef(0) + const isRetryingRef = useRef(false) useEffect(() => { trackActiveRef.current = trackActive @@ -36,27 +35,9 @@ export function useWebRtcStream({ token }: UseWebRtcStreamOptions) { } }, []) - const checkServerActive = useCallback(async (): Promise => { - try { - const headers: Record = {} - if (token) { - headers.Authorization = `Bearer ${token}` - } - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 2000) - const response = await fetch("/api/host/status", { - headers, - signal: controller.signal, - }) - clearTimeout(timeoutId) - return response.ok - } catch { - return false - } - }, [token]) - const triggerRetry = useCallback(() => { - if (retryTimerRef.current) return + if (retryTimerRef.current || isRetryingRef.current) return + isRetryingRef.current = true if (retryCountRef.current >= MAX_RETRIES) { console.warn( @@ -64,124 +45,96 @@ export function useWebRtcStream({ token }: UseWebRtcStreamOptions) { ) setErrorHandle("Connection Failed") setError("Failed to establish stream session after multiple attempts") + isRetryingRef.current = false return } - if (sseSourceRef.current) { - sseSourceRef.current.close() - sseSourceRef.current = null - } - if (videoPcRef.current) { - videoPcRef.current.close() - videoPcRef.current = null + if (wsRef.current) { + try { + wsRef.current.close() + } catch {} + wsRef.current = null } - if (inputPcRef.current) { - inputPcRef.current.close() - inputPcRef.current = null + if (pcRef.current) { + try { + pcRef.current.close() + } catch {} + pcRef.current = null } setTrackActive(false) setVideoStream(null) - setActiveSessionId(null) const backoffDelay = Math.min(2000 * 2 ** retryCountRef.current, 30000) console.log( - `[WebRTC] Startup/transient network failure, retrying automatically (attempt ${retryCountRef.current + 1}/${MAX_RETRIES}) in ${backoffDelay / 1000} seconds...`, + `[WebRTC] Transient network failure, retrying automatically (attempt ${retryCountRef.current + 1}/${MAX_RETRIES}) in ${backoffDelay / 1000} seconds...`, ) retryTimerRef.current = setTimeout(() => { retryTimerRef.current = null + isRetryingRef.current = false retryCountRef.current += 1 setReconnectAttempt((prev) => prev + 1) }, backoffDelay) }, []) - const handleNetworkFailure = useCallback(async () => { - const isServerOnline = await checkServerActive() - if (isServerOnline) { - triggerRetry() - } else { - setErrorHandle("Server Error") - setError("Server has quit or is unreachable") - } - }, [checkServerActive, triggerRetry]) + const handleNetworkFailure = useCallback(() => { + triggerRetry() + }, [triggerRetry]) const reconnect = () => { if (retryTimerRef.current) { clearTimeout(retryTimerRef.current) retryTimerRef.current = null } - if (sseSourceRef.current) { - sseSourceRef.current.close() - sseSourceRef.current = null - } - if (videoPcRef.current) { - videoPcRef.current.close() - videoPcRef.current = null + isRetryingRef.current = false + if (wsRef.current) { + try { + wsRef.current.close() + } catch {} + wsRef.current = null } - if (inputPcRef.current) { - inputPcRef.current.close() - inputPcRef.current = null + if (pcRef.current) { + try { + pcRef.current.close() + } catch {} + pcRef.current = null } setErrorHandle(null) setError(null) setTrackActive(false) setVideoStream(null) - setActiveSessionId(null) retryCountRef.current = 0 setReconnectAttempt((prev) => prev + 1) } - // Session provisioning useEffect(() => { - const urlParams = new URLSearchParams(window.location.search) - const querySessionId = urlParams.get("session") - - if (querySessionId && reconnectAttempt === 0) { - setActiveSessionId(querySessionId) - return - } if (!token) return - fetch("/api/session", { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - }) - .then((r) => { - if (!r.ok) { - throw new Error(`Session creation failed with status ${r.status}`) - } - return r.json() - }) - .then((data) => { - if (data.sessionId) { - setActiveSessionId(data.sessionId) - // Update URL query param with new sessionId - const newUrl = new URL(window.location.href) - newUrl.searchParams.set("session", data.sessionId) - window.history.replaceState({}, "", newUrl.toString()) - } else { - throw new Error("Session ID missing from response") - } - }) - .catch((err) => { - console.error("[WebRTC] Session init failed:", err) - handleNetworkFailure() - }) - }, [token, reconnectAttempt, handleNetworkFailure]) + let isDisposed = false - useEffect(() => { - if (!activeSessionId) return + if (reconnectAttempt > 0) { + console.log( + `[WebRTC] Re-establishing WebRTC session (attempt ${reconnectAttempt})...`, + ) + } + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const wsUrl = `${protocol}//${window.location.host}/ws` + const ws = new WebSocket(wsUrl) + wsRef.current = ws - // ── Video PC: receives GStreamer stream, no DataChannel ────────────── - const videoPc = new RTCPeerConnection({ - iceServers: [], + const pc = new RTCPeerConnection({ + iceServers: [{ urls: "stun:stun.l.google.com:19302" }], bundlePolicy: "max-bundle", }) - videoPcRef.current = videoPc - videoPc.addTransceiver("video", { direction: "recvonly" }) + pcRef.current = pc - videoPc.ontrack = (event) => { + let dcUnordered: RTCDataChannel | null = null + let dcOrdered: RTCDataChannel | null = null + + pc.ontrack = (event) => { + if (isDisposed || isRetryingRef.current) return if (event.track.kind === "video" && event.streams[0]) { setVideoStream(event.streams[0]) setTrackActive(true) @@ -189,33 +142,31 @@ export function useWebRtcStream({ token }: UseWebRtcStreamOptions) { } } - videoPc.onicecandidate = async (event) => { - if (!event.candidate) return - try { - const response = await fetch("/api/webrtc/ice", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - sessionId: activeSessionId, - from: "viewer", - candidate: event.candidate.candidate, - sdpMid: event.candidate.sdpMid, - sdpMLineIndex: event.candidate.sdpMLineIndex, - }), - }) - if (!response.ok) { - throw new Error(`ICE candidate post failed: ${response.status}`) - } - } catch (err) { - console.error(err) - handleNetworkFailure() + pc.ondatachannel = (event) => { + if (isDisposed || isRetryingRef.current) return + const channel = event.channel + if (channel.label === "input-unordered") { + dcUnordered = channel + } else if (channel.label === "input-ordered") { + dcOrdered = channel + } + + if (dcUnordered && dcOrdered) { + registerDataChannel(dcUnordered, dcOrdered) + } + } + + pc.onicecandidate = (event) => { + if (isDisposed || isRetryingRef.current) return + if (event.candidate && ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ type: "ice", candidate: event.candidate.toJSON() }), + ) } } - const handleConnectionStateChange = (pc: RTCPeerConnection) => () => { + pc.onconnectionstatechange = () => { + if (isDisposed || isRetryingRef.current) return if ( pc.connectionState === "failed" || pc.connectionState === "disconnected" @@ -224,207 +175,66 @@ export function useWebRtcStream({ token }: UseWebRtcStreamOptions) { } } - videoPc.onconnectionstatechange = handleConnectionStateChange(videoPc) - - // ── Input PC: DataChannel only, no media ───────────────────────────── - const inputPc = new RTCPeerConnection({ iceServers: [] }) - inputPcRef.current = inputPc - - const dcUnordered = inputPc.createDataChannel("input-unordered", { - ordered: false, - maxRetransmits: 0, - }) - const dcOrdered = inputPc.createDataChannel("input-ordered", { - ordered: true, - }) - registerDataChannel(dcUnordered, dcOrdered) + const iceQueue: RTCIceCandidateInit[] = [] - inputPc.onicecandidate = async (event) => { - if (!event.candidate) return + ws.onmessage = async (event) => { + if (isDisposed || isRetryingRef.current) return try { - const response = await fetch("/api/webrtc/ice", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - sessionId: activeSessionId, - from: "viewer-input", - candidate: event.candidate.candidate, - sdpMid: event.candidate.sdpMid, - sdpMLineIndex: event.candidate.sdpMLineIndex, - }), - }) - if (!response.ok) { - throw new Error(`ICE candidate post failed: ${response.status}`) - } - } catch (err) { - console.error(err) - handleNetworkFailure() - } - } - - inputPc.onconnectionstatechange = handleConnectionStateChange(inputPc) - - // ── SSE bridge: handles both video offer and input-answer ──────────── - const sseParams = new URLSearchParams({ sessionId: activeSessionId }) - if (token) sseParams.set("token", token) - const sseUrl = `/api/webrtc/events?${sseParams.toString()}` - const sse = new EventSource(sseUrl) - sseSourceRef.current = sse - - const videoIceQueue: RTCIceCandidateInit[] = [] - const inputIceQueue: RTCIceCandidateInit[] = [] - - sse.onerror = (event) => { - sse.close() - console.error("[WebRTC] SSE error:", event) - handleNetworkFailure() - } - - sse.addEventListener("stream-error", (event) => { - try { - const data = JSON.parse(event.data) - setErrorHandle("Network Error") - setError(data.message || `Stream error: ${data.type}`) - } catch { - setErrorHandle("Network Error") - setError("Stream error occurred") - } - }) - - // Video: GStreamer offers, browser answers - sse.addEventListener("offer", async (event) => { - const data = JSON.parse(event.data) - if (!data.sdp) return - try { - await videoPc.setRemoteDescription( - new RTCSessionDescription({ type: "offer", sdp: data.sdp }), - ) - const answer = await videoPc.createAnswer() - await videoPc.setLocalDescription(answer) - const response = await fetch("/api/webrtc/answer", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ sessionId: activeSessionId, sdp: answer.sdp }), - }) - if (!response.ok) { - throw new Error(`Answer post failed: ${response.status}`) - } - // Process queued video candidates - while (videoIceQueue.length > 0) { - const cand = videoIceQueue.shift() - if (cand) { - await videoPc - .addIceCandidate(new RTCIceCandidate(cand)) + const msg = JSON.parse(event.data) + if (msg.type === "offer") { + await pc.setRemoteDescription(msg.sdp) + const answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + ws.send(JSON.stringify({ type: "answer", sdp: answer })) + + while (iceQueue.length > 0) { + const cand = iceQueue.shift() + if (cand) { + await pc + .addIceCandidate(new RTCIceCandidate(cand)) + .catch(() => {}) + } + } + } else if (msg.type === "ice" && msg.candidate) { + if (pc.remoteDescription) { + await pc + .addIceCandidate(new RTCIceCandidate(msg.candidate)) .catch(() => {}) + } else { + iceQueue.push(msg.candidate) } + } else if (msg.type === "error") { + console.error("[WebRTC] Host error received:", msg) + setErrorHandle(msg.errorType || "Host Error") + setError(msg.message || "Host reported an error") } } catch (err) { - console.error("[WebRTC] Video offer handling failed:", err) - handleNetworkFailure() - } - }) - - // Video: GStreamer ICE candidates - sse.addEventListener("host-ice", async (event) => { - const data = JSON.parse(event.data) - if (!data.candidate) return - const candidateInit = { - candidate: data.candidate, - sdpMid: data.sdpMid, - sdpMLineIndex: data.sdpMLineIndex, - } - if (videoPc.remoteDescription) { - try { - await videoPc.addIceCandidate(new RTCIceCandidate(candidateInit)) - } catch {} - } else { - videoIceQueue.push(candidateInit) - } - }) - - // Input: server sends back its answer to our input offer - sse.addEventListener("input-answer", async (event) => { - const data = JSON.parse(event.data) - if (!data.sdp) return - try { - await inputPc.setRemoteDescription( - new RTCSessionDescription({ type: "answer", sdp: data.sdp }), - ) - // Process queued input candidates - while (inputIceQueue.length > 0) { - const cand = inputIceQueue.shift() - if (cand) { - await inputPc - .addIceCandidate(new RTCIceCandidate(cand)) - .catch((err) => { - console.error( - "[WebRTC] Failed to add queued input candidate:", - err, - ) - }) - } + if (!isDisposed && !isRetryingRef.current) { + console.error("[WebRTC] WebSocket message handling failed:", err) + handleNetworkFailure() } - } catch (err) { - console.error("[WebRTC] Input answer failed:", err) - handleNetworkFailure() } - }) + } - // Input: server sends back its ICE candidates - sse.addEventListener("input-ice", async (event) => { - const data = JSON.parse(event.data) - if (!data.candidate) return - const candidateInit = { - candidate: data.candidate, - sdpMid: data.sdpMid, - sdpMLineIndex: data.sdpMLineIndex, - } - if (inputPc.remoteDescription) { - try { - await inputPc.addIceCandidate(new RTCIceCandidate(candidateInit)) - } catch (err) { - console.error("[WebRTC] Failed to add input candidate:", err) - } - } else { - inputIceQueue.push(candidateInit) - } - }) - const sendInputOffer = async () => { - const offer = await inputPc.createOffer() - await inputPc.setLocalDescription(offer) - const response = await fetch("/api/webrtc/input-offer", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ sessionId: activeSessionId, sdp: offer.sdp }), - }) - if (!response.ok) { - throw new Error(`[WebRTC] Input offer failed: ${response.status}`) - } + ws.onerror = (error) => { + if (isDisposed || isRetryingRef.current) return + console.error("[WebRTC] WebSocket error:", error) + handleNetworkFailure() } - sendInputOffer().catch((err) => { - console.error(err) + ws.onclose = () => { + if (isDisposed || isRetryingRef.current) return + console.warn("[WebRTC] WebSocket closed") handleNetworkFailure() - }) + } - // Video stream watchdog to automatically recover from silent stream freezes let lastBytesReceived = 0 let lastBytesTime = Date.now() - const cancelled = false const statsInterval = setInterval(async () => { - if (cancelled || !trackActiveRef.current) return + if (isDisposed || isRetryingRef.current || !trackActiveRef.current) return try { - const stats = await videoPc.getStats() + const stats = await pc.getStats() let videoInbound = null for (const report of stats.values()) { if (report.type === "inbound-rtp" && report.kind === "video") { @@ -438,9 +248,9 @@ export function useWebRtcStream({ token }: UseWebRtcStreamOptions) { if (bytes > lastBytesReceived) { lastBytesReceived = bytes lastBytesTime = now - } else if (now - lastBytesTime > 4000) { + } else if (now - lastBytesTime > 15000) { console.warn( - "[WebRTC] Video stream freeze detected (no bytes received for 4s), reconnecting...", + "[WebRTC] Video stream freeze detected, reconnecting...", ) handleNetworkFailure() } @@ -451,14 +261,18 @@ export function useWebRtcStream({ token }: UseWebRtcStreamOptions) { }, 2000) return () => { + isDisposed = true clearInterval(statsInterval) - sse.close() - videoPc.close() - inputPc.close() + try { + ws.close() + } catch {} + try { + pc.close() + } catch {} setTrackActive(false) setVideoStream(null) } - }, [activeSessionId, token, registerDataChannel, handleNetworkFailure]) + }, [token, registerDataChannel, handleNetworkFailure, reconnectAttempt]) return { trackActive, diff --git a/src/server/api/InputPeerConnection.ts b/src/server/api/InputPeerConnection.ts deleted file mode 100644 index 0d50823a..00000000 --- a/src/server/api/InputPeerConnection.ts +++ /dev/null @@ -1,160 +0,0 @@ -import nodeDataChannel, { type PeerConnection } from "node-datachannel" -import { InputHandler } from "../InputHandler" -import logger from "../../utils/logger" -import type { InputMessage, InputConfig } from "../types" - -// Optimize SCTP settings globally for ultra-low latency. -// Disabling delayed SACK (setting it to 0ms) prevents acknowledgment delays. -// This keeps the browser client's transmission window fully open and avoids input lag bursts. -nodeDataChannel.setSctpSettings({ - delayedSackTime: 0, -}) - -const VALID_INPUT_TYPES = new Set([ - "move", - "click", - "scroll", - "key", - "text", - "zoom", - "combo", - "copy", - "paste", - "touch", -]) - -export class InputPeerConnection { - private pc: PeerConnection - private inputHandler: InputHandler - private sessionId: string - private answerResolve: ((sdp: string) => void) | null = null - - constructor( - sessionId: string, - onLocalCandidate?: (candidate: string, mid: string) => void, - initialConfig?: Partial, - onClosed?: () => void, - onError?: (errorType: string, message: string) => void, - ) { - this.sessionId = sessionId - this.inputHandler = new InputHandler( - initialConfig, - 8, - onError ? (errorType, message) => onError(errorType, message) : undefined, - ) - - this.pc = new nodeDataChannel.PeerConnection(`input-${sessionId}`, { - iceServers: [], - enableIceTcp: false, - }) - - if (onLocalCandidate) { - this.pc.onLocalCandidate((candidate, mid) => { - onLocalCandidate(candidate, mid) - }) - } - - this.pc.onDataChannel((dc) => { - logger.info( - `[Input] DataChannel "${dc.getLabel()}" open for session: ${sessionId}`, - ) - - dc.onMessage((msg) => { - try { - const raw = typeof msg === "string" ? msg : msg.toString() - const parsed = JSON.parse(raw) as { - type?: string - timestamp?: number - } - - if (parsed.type === "ping") { - dc.sendMessage( - JSON.stringify({ type: "pong", timestamp: parsed.timestamp }), - ) - return - } - - if (!parsed.type || !VALID_INPUT_TYPES.has(parsed.type)) { - logger.warn(`[Input] Unknown type: ${parsed.type}`) - return - } - - this.inputHandler - .handleMessage(parsed as InputMessage) - .catch((err) => - logger.error(`[Input] Handler error: ${String(err)}`), - ) - } catch (err) { - logger.error(`[Input] Parse error: ${String(err)}`) - } - }) - - dc.onClosed(() => { - logger.info(`[Input] DataChannel closed: ${sessionId}`) - }) - }) - - this.pc.onLocalDescription((sdp, type) => { - if (type === "answer" && this.answerResolve) { - this.answerResolve(sdp) - this.answerResolve = null - } - }) - - let closedCalled = false - this.pc.onStateChange((state) => { - logger.info(`[Input] PC state [${sessionId}]: ${state}`) - if ( - state === "closed" || - state === "disconnected" || - state === "failed" - ) { - if (onClosed && !closedCalled) { - closedCalled = true - onClosed() - } - } - }) - } - - processOffer(offerSdp: string): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Input PC answer generation timed out")) - }, 10_000) - - this.answerResolve = (sdp) => { - clearTimeout(timeout) - resolve(sdp) - } - - try { - logger.info(offerSdp.split("\n").slice(0, 10).join("\n")) - this.pc.setRemoteDescription(offerSdp, "offer") - } catch (err) { - clearTimeout(timeout) - reject(err) - } - }) - } - - addRemoteCandidate(candidate: string, mid: string): void { - try { - this.pc.addRemoteCandidate(candidate, mid) - } catch (err) { - logger.warn(`[Input] Failed to add candidate: ${String(err)}`) - } - } - - updateConfig(config: Partial): void { - this.inputHandler.updateConfig(config) - } - - close(): void { - try { - this.pc.close() - } catch {} - this.inputHandler.destroy() - logger.info(`[Input] Connection closed: ${this.sessionId}`) - } -} diff --git a/src/server/api/apiHandlers.ts b/src/server/api/apiHandlers.ts deleted file mode 100644 index f71015f4..00000000 --- a/src/server/api/apiHandlers.ts +++ /dev/null @@ -1,754 +0,0 @@ -/** - * Request handlers and authorization middleware for the HTTP signaling layer. - */ - -import type { IncomingMessage, ServerResponse } from "node:http" -import fs from "node:fs" -import crypto from "node:crypto" -import logger from "../../utils/logger" -import { - getActiveToken, - isKnownToken, - touchToken, - generateToken, - storeToken, -} from "../tokenStore" -import { InputPeerConnection } from "./InputPeerConnection" -import { getLocalIp } from "./getLocalIp" -import type { InputConfig } from "../types" -import { - sessions, - sseClients, - inputConnections, - hostStatus, - runnerInstance, - pendingConfigUpdates, - setHostStatus, - setRunnerInstance, - setPendingConfigUpdates, - pushEvent, - ensureHostRunnerActive, - reinStorage, -} from "./apiState" - -// --- Auth & Request Utilities --- - -export function isLocalRequest(req: IncomingMessage): boolean { - const addr = req.socket.remoteAddress - if (!addr) return false - return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1" -} - -export function requireLocalhost( - req: IncomingMessage, - res: ServerResponse, -): boolean { - if (isLocalRequest(req)) return true - reinStorage.run(true, () => { - res.writeHead(403, { "Content-Type": "application/json" }) - res.end(JSON.stringify({ error: "Localhost only" })) - }) - return false -} - -export function requireAuth( - req: IncomingMessage, - res: ServerResponse, -): boolean { - if (isLocalRequest(req)) return true - - const authHeader = req.headers.authorization ?? "" - let token = authHeader.startsWith("Bearer ") - ? authHeader.slice(7).trim() - : null - - if (!token) { - const url = new URL(req.url ?? "", `http://${req.headers.host}`) - token = url.searchParams.get("token") - } - - if (!token || !isKnownToken(token)) { - reinStorage.run(true, () => { - res.writeHead(401, { "Content-Type": "application/json" }) - res.end(JSON.stringify({ error: "Unauthorized" })) - }) - return false - } - - touchToken(token) - return true -} - -export function json(res: ServerResponse, status: number, body: unknown): void { - const payload = JSON.stringify(body) - reinStorage.run(true, () => { - res.writeHead(status, { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(payload), - }) - res.end(payload) - }) -} - -export async function readBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let raw = "" - req.setEncoding("utf-8") - req.on("data", (chunk: string) => { - raw += chunk - if (raw.length > 64 * 1024) { - req.destroy() - reject(new Error("Request body too large")) - } - }) - req.on("end", () => resolve(raw)) - req.on("error", reject) - }) -} - -// --- Route Handlers --- - -export async function handleCreateSession( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAuth(req, res)) return - - try { - const session = { - id: crypto.randomUUID(), - createdAt: Date.now(), - state: "pending" as const, - offer: null, - answer: null, - viewerIce: [], - hostIce: [], - } - sessions.set(session.id, session) - logger.info(`Session created: ${session.id}`) - - const hostHeader = req.headers.host || "127.0.0.1:8000" - ensureHostRunnerActive(`http://${hostHeader}`) - - const protocol = req.headers["x-forwarded-proto"] ?? "http" - const host = req.headers.host ?? "localhost" - const viewerUrl = `${protocol}://${host}/trackpad?session=${session.id}` - json(res, 201, { sessionId: session.id, viewerUrl }) - } catch (_err) { - json(res, 500, { - error: "Failed to initialize and bind session host runtime cleanly", - }) - } -} - -export function handleGetSession( - req: IncomingMessage, - res: ServerResponse, - sessionId: string, -): void { - if (!requireAuth(req, res)) return - - const session = sessions.get(sessionId) - if (!session) { - json(res, 404, { error: "Session not found" }) - return - } - - json(res, 200, { - id: session.id, - state: session.state, - createdAt: session.createdAt, - hasOffer: session.offer !== null, - hasAnswer: session.answer !== null, - viewerIceCandidates: session.viewerIce.length, - hostIceCandidates: session.hostIce.length, - }) -} - -export function handleDeleteSession( - req: IncomingMessage, - res: ServerResponse, - sessionId: string, -): void { - if (!requireAuth(req, res)) return - - const session = sessions.get(sessionId) - if (!session) { - json(res, 404, { error: "Session not found" }) - return - } - - session.state = "closed" - sessions.delete(sessionId) - pushEvent(sessionId, "session-closed", { sessionId }) - sseClients.delete(sessionId) - inputConnections.get(sessionId)?.close() - inputConnections.delete(sessionId) - logger.info(`Session deleted: ${sessionId}`) - json(res, 200, { ok: true }) -} - -export async function handleOffer( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAuth(req, res)) return - - const bodyText = await readBody(req) - const { sessionId, sdp } = JSON.parse(bodyText || "{}") - - if (!sessionId || typeof sdp !== "string") { - json(res, 400, { error: "sessionId and sdp are required" }) - return - } - - const session = sessions.get(sessionId) - if (!session) { - json(res, 404, { error: "Session not found" }) - return - } - if (session.state !== "pending") { - json(res, 409, { - error: `Session is in state '${session.state}', expected 'pending'`, - }) - return - } - - session.offer = sdp - session.state = "offering" - logger.info(`SDP offer received for session ${sessionId}`) - - pushEvent(sessionId, "offer", { sessionId, sdp }) - - const hostHeader = req.headers.host || "127.0.0.1:8000" - const runner = ensureHostRunnerActive(`http://${hostHeader}`) - runner.handleIncomingClientOffer(sessionId, sdp) - - json(res, 200, { ok: true }) -} - -export async function handleAnswer( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAuth(req, res)) return - - const bodyText = await readBody(req) - const { sessionId, sdp } = JSON.parse(bodyText || "{}") - - if (!sessionId || typeof sdp !== "string") { - json(res, 400, { error: "sessionId and sdp are required" }) - return - } - - const session = sessions.get(sessionId) - if (!session) { - json(res, 404, { error: "Session not found" }) - return - } - - session.answer = sdp - session.state = "answered" - logger.info(`SDP answer stored for session ${sessionId}`) - - pushEvent(sessionId, "answer", { sessionId, sdp }) - json(res, 200, { ok: true }) -} - -export async function handleIce( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAuth(req, res)) return - - const bodyText = await readBody(req) - const body = JSON.parse(bodyText || "{}") - const { sessionId, candidate, sdpMid, sdpMLineIndex, from } = body - - if (!sessionId || typeof candidate !== "string" || !from) { - json(res, 400, { error: "sessionId, candidate, and from are required" }) - return - } - - const session = sessions.get(sessionId) - if (!session) { - json(res, 404, { error: "Session not found" }) - return - } - - const ice = { - candidate, - sdpMid: sdpMid ?? null, - sdpMLineIndex: sdpMLineIndex ?? null, - } - if (from === "viewer-input") { - const inputPc = inputConnections.get(sessionId) - if (inputPc && ice.sdpMid !== null) { - inputPc.addRemoteCandidate(ice.candidate, ice.sdpMid) - } - json(res, 200, { ok: true }) - return - } else if (from === "viewer") { - session.viewerIce.push(ice) - pushEvent(sessionId, "viewer-ice", { sessionId, ...ice }) - } else { - session.hostIce.push(ice) - pushEvent(sessionId, "host-ice", { sessionId, ...ice }) - } - - json(res, 200, { ok: true }) -} - -export function handleEvents(req: IncomingMessage, res: ServerResponse): void { - if (!requireAuth(req, res)) return - - const url = new URL(req.url ?? "", `http://${req.headers.host}`) - const sessionId = url.searchParams.get("sessionId") - - if (!sessionId) { - json(res, 400, { error: "sessionId query param required" }) - return - } - - const session = sessions.get(sessionId) - if (!session) { - json(res, 404, { error: "Session not found" }) - return - } - - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "Access-Control-Allow-Origin": "*", - }) - res.flushHeaders() - - if (!sseClients.has(sessionId)) { - sseClients.set(sessionId, new Set()) - } - sseClients.get(sessionId)?.add(res) - - if (!session.offer && runnerInstance) { - logger.info( - `SSE viewer connected — starting GStreamer pipeline for session: ${sessionId}`, - ) - runnerInstance.handleIncomingClientOffer(sessionId, "") - } - - if (session.offer) { - res.write( - `event: offer\ndata: ${JSON.stringify({ sessionId: session.id, sdp: session.offer })}\n\n`, - ) - } - if (session.answer) { - res.write( - `event: answer\ndata: ${JSON.stringify({ sessionId: session.id, sdp: session.answer })}\n\n`, - ) - } - for (const ice of session.viewerIce) { - res.write( - `event: viewer-ice\ndata: ${JSON.stringify({ sessionId: session.id, ...ice })}\n\n`, - ) - } - for (const ice of session.hostIce) { - res.write( - `event: host-ice\ndata: ${JSON.stringify({ sessionId: session.id, ...ice })}\n\n`, - ) - } - - const keepAlive = setInterval(() => { - try { - reinStorage.run(true, () => { - res.write(": keepalive\n\n") - }) - } catch { - clearInterval(keepAlive) - } - }, 15_000) - - req.on("close", () => { - clearInterval(keepAlive) - sseClients.get(sessionId)?.delete(res) - logger.info(`SSE client disconnected from session ${sessionId}`) - }) - - logger.info(`SSE client connected to session ${sessionId}`) -} - -export async function handleGstSignalingGateway( - req: IncomingMessage, - res: ServerResponse, -): Promise { - const url = new URL(req.url ?? "", `http://${req.headers.host}`) - const sessionId = url.searchParams.get("sessionId") - - if (!sessionId) { - json(res, 400, { error: "sessionId target query missing" }) - return - } - - const bodyText = await readBody(req) - try { - const msg = JSON.parse(bodyText) - - if (msg.type === "answer") { - logger.info(`GStreamer SDP answer received for session: ${sessionId}`) - pushEvent(sessionId, "answer", { sessionId, sdp: msg.sdp }) - } else if (msg.candidate) { - pushEvent(sessionId, "host-ice", { - sessionId, - candidate: msg.candidate, - sdpMid: msg.sdpMid, - sdpMLineIndex: msg.sdpMLineIndex, - }) - } - json(res, 200, { status: "ok" }) - } catch (_err) { - json(res, 400, { error: "Inbound parsing crash" }) - } -} - -export async function handleHostStart( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireLocalhost(req, res)) return - if (hostStatus === "running" || hostStatus === "starting") { - json(res, 409, { error: `Host already ${hostStatus}` }) - return - } - try { - const hostHeader = req.headers.host || "127.0.0.1:8000" - ensureHostRunnerActive(`http://${hostHeader}`) - json(res, 200, { status: hostStatus }) - } catch (err) { - json(res, 500, { error: String(err) }) - } -} - -export async function handleHostStop( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireLocalhost(req, res)) return - if (hostStatus === "stopped" || !runnerInstance) { - json(res, 409, { error: "Host is not running" }) - return - } - - runnerInstance.shutdown() - setRunnerInstance(null) - setHostStatus("stopped") - json(res, 200, { status: hostStatus }) -} - -export function handleHostStatus( - req: IncomingMessage, - res: ServerResponse, -): void { - if (!requireAuth(req, res)) return - json(res, 200, { status: hostStatus }) -} - -export async function handleGenerateToken( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireLocalhost(req, res)) return - - let token = getActiveToken() - if (!token) { - token = generateToken() - storeToken(token) - } - json(res, 200, { token }) -} - -export function handleGetToken( - req: IncomingMessage, - res: ServerResponse, -): void { - if (!requireLocalhost(req, res)) return - const token = getActiveToken() - if (!token) { - json(res, 404, { error: "No active token" }) - return - } - json(res, 200, { token }) -} - -export async function handleInputOffer( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAuth(req, res)) return - - const bodyText = await readBody(req) - const { sessionId, sdp } = JSON.parse(bodyText || "{}") as { - sessionId?: string - sdp?: string - } - - if (!sessionId || typeof sdp !== "string") { - json(res, 400, { error: "sessionId and sdp are required" }) - return - } - if (!sessions.has(sessionId)) { - json(res, 404, { error: "Session not found" }) - return - } - if (inputConnections.has(sessionId)) { - json(res, 200, { ok: true }) - return - } - - let initialSensitivity = 1.0 - let initialInvertScroll = false - try { - const configPath = "./src/server-config.json" - if (fs.existsSync(configPath)) { - const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8")) - if (typeof cfg.sensitivity === "number") - initialSensitivity = cfg.sensitivity - if (typeof cfg.invertScroll === "boolean") - initialInvertScroll = cfg.invertScroll - } - } catch (e) { - logger.warn( - `Failed to read initial config from server-config.json: ${String(e)}`, - ) - } - - // Overlay any pending (unsaved) config - if (pendingConfigUpdates) { - if (typeof pendingConfigUpdates.sensitivity === "number") - initialSensitivity = pendingConfigUpdates.sensitivity - if (typeof pendingConfigUpdates.invertScroll === "boolean") - initialInvertScroll = pendingConfigUpdates.invertScroll - } - - const inputPc = new InputPeerConnection( - sessionId, - (candidate, mid) => { - pushEvent(sessionId, "input-ice", { - sessionId, - candidate, - sdpMid: mid, - sdpMLineIndex: null, - }) - }, - { sensitivity: initialSensitivity, invertScroll: initialInvertScroll }, - () => { - logger.info(`Input connection closed: ${sessionId}`) - inputConnections.delete(sessionId) - pushEvent(sessionId, "stream-error", { - type: "input-closed", - message: "Input connection closed", - }) - }, - (errorType, message) => { - pushEvent(sessionId, "stream-error", { type: errorType, message }) - }, - ) - inputConnections.set(sessionId, inputPc) - - try { - const answerSdp = await inputPc.processOffer(sdp) - pushEvent(sessionId, "input-answer", { sessionId, sdp: answerSdp }) - logger.info(`[Input] Answer dispatched via SSE for: ${sessionId}`) - json(res, 200, { ok: true }) - } catch (err) { - inputConnections.delete(sessionId) - inputPc.close() - json(res, 500, { error: String(err) }) - } -} - -export async function handleGetIp( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAuth(req, res)) return - try { - const ip = await getLocalIp() - json(res, 200, { ip }) - } catch (err) { - logger.error(`Failed to get local IP: ${String(err)}`) - json(res, 500, { error: "Failed to get local IP" }) - } -} - -export async function handleUpdateConfig( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAuth(req, res)) return - - try { - const bodyText = await readBody(req) - const body = JSON.parse(bodyText || "{}") as Record - - const configPath = "./src/server-config.json" - const SERVER_CONFIG_KEYS = [ - "host", - "frontendPort", - "address", - "inputThrottleMs", - "sensitivity", - "invertScroll", - ] as const - - const filtered: Record = {} - - for (const key of SERVER_CONFIG_KEYS) { - if (!(key in body)) continue - - if (key === "frontendPort") { - const port = Number(body[key]) - if ( - !Number.isFinite(port) || - port < 1 || - port > 65535 || - Math.floor(port) !== port - ) { - json(res, 400, { error: "Invalid port number (must be 1–65535)" }) - return - } - filtered[key] = port - } else if (key === "inputThrottleMs") { - const ms = Number(body[key]) - if (!Number.isFinite(ms) || ms < 1 || ms > 1000) { - json(res, 400, { error: "Invalid inputThrottleMs (must be 1–1000)" }) - return - } - filtered[key] = ms - } else if (key === "sensitivity") { - const sens = Number(body[key]) - if (!Number.isFinite(sens) || sens < 0.1 || sens > 10.0) { - json(res, 400, { error: "Invalid sensitivity (must be 0.1–10.0)" }) - return - } - filtered[key] = sens - } else if (key === "invertScroll") { - if (typeof body[key] !== "boolean") { - json(res, 400, { error: "invertScroll must be a boolean" }) - return - } - filtered[key] = body[key] - } else if ( - typeof body[key] === "string" && - (body[key] as string).length <= 255 - ) { - filtered[key] = body[key] - } - } - - if (Object.keys(filtered).length === 0) { - json(res, 400, { error: "No valid config keys provided" }) - return - } - - // Buffer config changes to prevent Vite server restart - setPendingConfigUpdates({ ...(pendingConfigUpdates || {}), ...filtered }) - logger.info( - "Configuration updates cached. Changes will be written to server-config.json when Vite/server exits.", - ) - - // Immediately propagate configuration changes to all active input connections - const inputConfigUpdate: Partial = {} - if (typeof filtered.sensitivity === "number") { - inputConfigUpdate.sensitivity = filtered.sensitivity - } - if (typeof filtered.invertScroll === "boolean") { - inputConfigUpdate.invertScroll = filtered.invertScroll - } - - if (Object.keys(inputConfigUpdate).length > 0) { - for (const inputPc of inputConnections.values()) { - inputPc.updateConfig(inputConfigUpdate) - } - } - - const current = fs.existsSync(configPath) - ? (JSON.parse(fs.readFileSync(configPath, "utf-8")) as Record< - string, - unknown - >) - : {} - const newConfig = { ...current, ...filtered } - json(res, 200, { ok: true, config: newConfig }) - } catch (err) { - logger.error(`Failed to update config: ${String(err)}`) - json(res, 500, { error: String(err) }) - } -} - -export async function handleWhipSignalingExchange( - req: IncomingMessage, - res: ServerResponse, -): Promise { - const url = new URL(req.url ?? "", `http://${req.headers.host}`) - const sessionId = url.searchParams.get("sessionId") - - if (!sessionId) { - json(res, 400, { error: "sessionId parameter context missing" }) - return - } - - let token = url.searchParams.get("token") - if (!token) { - const authHeader = req.headers.authorization ?? "" - if (authHeader.startsWith("Bearer ")) { - token = authHeader.slice(7).trim() - } else if (authHeader.startsWith("Bearer_")) { - token = authHeader.slice(7).trim() - } - } - - if (!token || !isKnownToken(token)) { - json(res, 401, { error: "Unauthorized" }) - return - } - - const hostOfferSdp = await readBody(req) - logger.info(`WHIP offer received for session: ${sessionId}`) - - const session = sessions.get(sessionId) - if (!session) { - json(res, 404, { error: "Target session context not found" }) - return - } - - session.offer = hostOfferSdp - session.state = "offering" - - pushEvent(sessionId, "offer", { sessionId, sdp: hostOfferSdp }) - - let checkCount = 0 - const answerCheckInterval = setInterval(() => { - reinStorage.run(true, () => { - const activeSession = sessions.get(sessionId) - checkCount++ - - if (activeSession?.answer) { - clearInterval(answerCheckInterval) - res.writeHead(201, { - "Content-Type": "application/sdp", - Location: `/api/webrtc/whip?sessionId=${sessionId}`, - }) - res.end(activeSession.answer) - logger.info(`WHIP handshake complete for session: ${sessionId}`) - } else if ( - checkCount >= 50 || - !activeSession || - activeSession.state === "closed" - ) { - clearInterval(answerCheckInterval) - res.writeHead(408, { "Content-Type": "application/json" }) - res.end(JSON.stringify({ error: "WHIP signaling handshake timeout" })) - } - }) - }, 100) - req.on("close", () => clearInterval(answerCheckInterval)) -} diff --git a/src/server/api/apiState.ts b/src/server/api/apiState.ts deleted file mode 100644 index 14c8a55b..00000000 --- a/src/server/api/apiState.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Shared memory state and state operations for the HTTP signaling layer. - */ - -import type { ServerResponse } from "node:http" -import { AsyncLocalStorage } from "node:async_hooks" -import { getActiveToken, generateToken, storeToken } from "../tokenStore" -import { HostRunner } from "../gstreamer/hostRunner" -import type { InputPeerConnection } from "./InputPeerConnection" -import logger from "../../utils/logger" - -export type SessionState = - | "pending" - | "offering" - | "answered" - | "connected" - | "closed" - -export interface IceCandidate { - candidate: string - sdpMid: string | null - sdpMLineIndex: number | null -} - -export interface Session { - id: string - createdAt: number - state: SessionState - offer: string | null - answer: string | null - viewerIce: IceCandidate[] - hostIce: IceCandidate[] -} - -export type HostStatus = "stopped" | "starting" | "running" | "error" - -export const reinStorage = new AsyncLocalStorage() - -export const sessions = new Map() -export const sseClients = new Map>() -export const inputConnections = new Map() - -export let hostStatus: HostStatus = "stopped" -export let runnerInstance: HostRunner | null = null -export let pendingConfigUpdates: Record | null = null - -export function setHostStatus(status: HostStatus): void { - hostStatus = status -} - -export function setRunnerInstance(runner: HostRunner | null): void { - runnerInstance = runner -} - -export function setPendingConfigUpdates( - updates: Record | null, -): void { - pendingConfigUpdates = updates -} - -export function pushEvent( - sessionId: string, - event: string, - data: unknown, -): void { - const clients = sseClients.get(sessionId) - if (!clients || clients.size === 0) return - - const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` - for (const res of clients) { - try { - reinStorage.run(true, () => { - res.write(payload) - }) - } catch { - clients.delete(res) - } - } -} - -export function ensureHostRunnerActive(localBaseUrl: string): HostRunner { - if (runnerInstance) return runnerInstance - - setHostStatus("starting") - logger.info("Initializing GStreamer HostRunner") - - try { - const localToken = getActiveToken() ?? generateToken() - storeToken(localToken) - - const runner = new HostRunner( - localBaseUrl, - localToken, - (sessionId, errorType, message) => { - pushEvent(sessionId, "stream-error", { type: errorType, message }) - }, - ) - setRunnerInstance(runner) - setHostStatus("running") - logger.info("GStreamer HostRunner is running") - return runner - } catch (err) { - setHostStatus("error") - logger.error(`Critical error initializing HostRunner: ${String(err)}`) - throw err - } -} diff --git a/src/server/api/getLocalIp.ts b/src/server/api/getLocalIp.ts deleted file mode 100644 index add5da6b..00000000 --- a/src/server/api/getLocalIp.ts +++ /dev/null @@ -1,44 +0,0 @@ -import dgram from "node:dgram" - -export async function getLocalIp(): Promise { - return new Promise((resolve) => { - const socket = dgram.createSocket("udp4") - let settled = false - - const finish = (ip: string) => { - if (settled) return - settled = true - - clearTimeout(timeout) - socket.removeAllListeners("connect") - socket.removeAllListeners("error") - - try { - socket.close() - } catch { - // socket may already be closed - } - - resolve(ip) - } - - const timeout = setTimeout(() => { - finish("127.0.0.1") - }, 1000) - - socket.connect(1, "1.1.1.1") - - socket.on("connect", () => { - const addr = socket.address() - if (typeof addr === "object") { - finish(addr.address) - } else { - finish("127.0.0.1") - } - }) - - socket.on("error", () => { - finish("127.0.0.1") - }) - }) -} diff --git a/src/server/drivers/linux/index.ts b/src/server/drivers/linux/index.ts index 8b5eddb5..4c32dfcb 100644 --- a/src/server/drivers/linux/index.ts +++ b/src/server/drivers/linux/index.ts @@ -260,12 +260,12 @@ export class LinuxInputInjector { const touchOk = this.setupTouchDevice() if (!mouseOk || !kbOk || !touchOk) { - console.error( - "[LinuxInputInjector] One or more devices failed to initialize", - ) + const msg = + "One or more virtual uinput devices failed to initialize (check /dev/uinput permissions)" + console.error(`[LinuxInputInjector] ${msg}`) this.destroy() - this.initialized = mouseOk - return + this.initialized = false + throw new Error(msg) } this.keyboard = new LinuxKeyboard(this.kbDev.fd) diff --git a/src/server/drivers/linux/structs.ts b/src/server/drivers/linux/structs.ts index 70beb39a..fc9e1c0c 100644 --- a/src/server/drivers/linux/structs.ts +++ b/src/server/drivers/linux/structs.ts @@ -41,7 +41,7 @@ let _open: KoffiFunc | null = null let _close: KoffiFunc | null = null let _write: KoffiFunc | null = null let _ioctl: KoffiFunc | null = null -let _dummyBuffer: Buffer | null = null +export let _dummyBuffer: Buffer | null = null function ensureLibc() { if (!_libc) { diff --git a/src/server/drivers/mac/structs.ts b/src/server/drivers/mac/structs.ts index bb8f2c14..39ef6527 100644 --- a/src/server/drivers/mac/structs.ts +++ b/src/server/drivers/mac/structs.ts @@ -24,8 +24,8 @@ let _CGEventCreateScrollWheelEvent: koffi.KoffiFunction | null = null let _CGEventPost: koffi.KoffiFunction | null = null let _CFRelease: koffi.KoffiFunction | null = null let _CGEventSetIntegerValueField: koffi.KoffiFunction | null = null -let _CGEventSetDoubleValueField: koffi.KoffiFunction | null = null -let _CGEventGetLocation: koffi.KoffiFunction | null = null +export let _CGEventSetDoubleValueField: koffi.KoffiFunction | null = null +export let _CGEventGetLocation: koffi.KoffiFunction | null = null function ensureFunctions() { const lib = cg() diff --git a/src/server/drivers/windows/structs.ts b/src/server/drivers/windows/structs.ts index 31beb924..7f61b49c 100644 --- a/src/server/drivers/windows/structs.ts +++ b/src/server/drivers/windows/structs.ts @@ -42,7 +42,7 @@ const POINTER_TOUCH_INFO = koffi.struct("POINTER_TOUCH_INFO", { pressure: "uint32", }) -const _POINTER_TYPE_INFO = koffi.struct("POINTER_TYPE_INFO", { +export const _POINTER_TYPE_INFO = koffi.struct("POINTER_TYPE_INFO", { type: "uint32", touchInfo: POINTER_TOUCH_INFO, }) diff --git a/src/server/gstreamer/gstManager.ts b/src/server/gstreamer/gstManager.ts index 2ba3645c..d9828790 100644 --- a/src/server/gstreamer/gstManager.ts +++ b/src/server/gstreamer/gstManager.ts @@ -1,127 +1,74 @@ -/** - * GStreamer pipeline process manager. - * - * Spawns and monitors the gst-launch-1.0 process for capturing and encoding - * the screen video stream, and streaming it via WHIP signaling client. - */ - import { spawn, type ChildProcess } from "node:child_process" -import EventEmitter from "node:events" import os from "node:os" import fs from "node:fs" import path from "node:path" import logger from "../../utils/logger" import { type CaptureProvider, createCaptureProvider } from "./captureProvider" -export class GstManager extends EventEmitter { +export class GstManager { private process: ChildProcess | null = null - private sessionId: string - private intentionalStop = false private provider: CaptureProvider | null = null - constructor(sessionId: string) { - super() - this.sessionId = sessionId - } - - private buildPipelineArgs( - sourceBlocks: string[], - token: string, - whipPort: number, - ): string[] { - const platform = os.platform() + private buildPipelineArgs(sourceBlocks: string[]): string[] { const args = [...sourceBlocks] - // Build the common suffix byte-for-byte identically to the old implementation - if (platform !== "win32") { - args.push( - "!", - "queue", - "max-size-buffers=5", - "leaky=downstream", - "!", - "videoconvert", - "!", - "videoscale", - "!", - "videorate", - ) - } else { - // Windows already appended d3d11convert/download in the source block - args.push("!", "videoconvert", "!", "videorate") - } - - if (platform === "darwin") { - args.push( - "!", - "video/x-raw,format=NV12,framerate=30/1", - "!", - "vtenc_h264", - "realtime=true", - "max-keyframe-interval=15", - "allow-frame-reordering=false", - "bitrate=2500", - "!", - "h264parse", - "config-interval=-1", - ) - } else { - args.push( - "!", - "video/x-raw,framerate=30/1", - "!", - "vp8enc", - "deadline=1", - "keyframe-max-dist=15", - "target-bitrate=2500000", - ) - } - - // Add WHIP sink args.push( "!", - "whipclientsink", - `signaller::whip-endpoint=http://localhost:${whipPort}/api/webrtc/whip?sessionId=${this.sessionId}&token=${token}`, - `signaller::auth-token=Bearer_${token}`, + "queue", + "max-size-buffers=1", + "leaky=downstream", + "!", + "videoconvert", + "!", + "videorate", + "!", + "video/x-raw,framerate=60/1", + "!", + "vp8enc", + "deadline=1", + "cpu-used=16", + "threads=4", + "end-usage=cbr", + "target-bitrate=3000000", + "buffer-initial-size=0", + "buffer-optimal-size=0", + /*"buffer-max-size=0",*/ + "keyframe-max-dist=30", + "error-resilient=default", + "!", + "rtpvp8pay", + "!", + "udpsink", + "host=127.0.0.1", + "port=5004", + "sync=false", + "async=false", ) return args } - public async start(token: string, whipPort: number): Promise { + public async start(): Promise { if (this.process) return - this.intentionalStop = false - logger.info("Spawning GStreamer WHIP engine") + logger.info("Spawning GStreamer UDP engine") try { this.provider = createCaptureProvider() await this.provider.initialize(async (err) => { logger.error(`Capture provider failed after startup: ${err.message}`) - if (this.process) { - this.intentionalStop = true - this.stop() - await this.cleanup() - } else { - await this.cleanup() - } - this.emit("capture-failure", err) + await this.stop() }) const sourceBlocks = await this.provider.getGStreamerSource() - const pipelineArgs = this.buildPipelineArgs(sourceBlocks, token, whipPort) - this.executePipeline(pipelineArgs, whipPort, token) + const pipelineArgs = this.buildPipelineArgs(sourceBlocks) + this.executePipeline(pipelineArgs) } catch (error) { logger.error(`Capture initialization failed: ${String(error)}`) - this.emit("capture-failure", error) await this.cleanup() } } - private executePipeline( - pipelineArgs: string[], - whipPort: number, - token: string, - ): void { + private executePipeline(pipelineArgs: string[]): void { const spawnedEnv = { ...process.env } if (!spawnedEnv.DISPLAY) spawnedEnv.DISPLAY = ":0" if (!spawnedEnv.XAUTHORITY) { @@ -142,14 +89,16 @@ export class GstManager extends EventEmitter { } } + logger.info(`GStreamer args: gst-launch-1.0 ${pipelineArgs.join(" ")}`) this.process = spawn("gst-launch-1.0", pipelineArgs, { env: spawnedEnv }) + this.process.on("error", async (err) => { logger.error(`GStreamer spawn failed: ${err.message}`) this.process = null await this.cleanup() - this.emit("capture-failure", err) return }) + this.process.stdout?.on("data", (data: Buffer) => { const output = data.toString() if (output.includes("State change") && output.includes("PLAYING")) { @@ -158,25 +107,13 @@ export class GstManager extends EventEmitter { }) this.process.stderr?.on("data", (data: Buffer) => { - let logStr = data.toString() - logStr = logStr.replace(/auth-token=\S+/g, "auth-token=REDACTED") + const logStr = data.toString() if ( - logStr.includes("ERROR") && - logStr.includes("pipeline doesn't want to preroll") - ) { - if (this.intentionalStop) return - logger.error( - "GStreamer pipeline failed to preroll, starting loopback fallback", - ) - this.intentionalStop = true - this.stop() - this.triggerTestFallbackPipeline(whipPort, token) - } else if ( logStr.includes("WARN") || logStr.includes("error") || logStr.includes("ERROR") ) { - logger.warn(`GStreamer [${this.sessionId}]: ${logStr.trim()}`) + logger.warn(`GStreamer: ${logStr.trim()}`) } }) @@ -184,60 +121,16 @@ export class GstManager extends EventEmitter { logger.info(`GStreamer process exited with status: ${code}`) this.process = null await this.cleanup() - if (!this.intentionalStop) { - this.emit("exit") - } }) } - private triggerTestFallbackPipeline(serverPort: number, token: string): void { - logger.info("Launching loopback video test pattern") - // (Existing fallback code remains identical) - const pipelineArgs = [ - "videotestsrc", - "is-live=true", - "pattern=ball", - "!", - "video/x-raw,framerate=30/1", - "!", - "videoconvert", - "!", - "vp8enc", - "deadline=1", - "keyframe-max-dist=15", - "target-bitrate=2500000", - "!", - "whipclientsink", - `signaller::whip-endpoint=http://localhost:${serverPort}/api/webrtc/whip?sessionId=${this.sessionId}&token=${token}`, - `signaller::auth-token=Bearer_${token}`, - ] - - const spawnedEnv = { ...process.env } - delete spawnedEnv.DISPLAY - delete spawnedEnv.XAUTHORITY - - const proc = spawn("gst-launch-1.0", pipelineArgs, { env: spawnedEnv }) - this.process = proc - this.intentionalStop = false - - proc.stderr?.on("data", (data: Buffer) => { - logger.warn( - `GStreamer fallback [${this.sessionId}]: ${data.toString().trim()}`, - ) - }) - - proc.on("close", (code) => { - logger.info(`GStreamer fallback exited with status: ${code}`) + public async stop(): Promise { + if (this.process) { + logger.info("Terminating GStreamer video pipeline") + this.process.kill("SIGTERM") this.process = null - this.emit("exit") - }) - } - - public stop(): void { - if (!this.process) return - logger.info("Terminating GStreamer video pipeline") - this.process.kill("SIGTERM") - this.process = null + } + await this.cleanup() } private async cleanup(): Promise { diff --git a/src/server/gstreamer/hostRunner.ts b/src/server/gstreamer/hostRunner.ts deleted file mode 100644 index 248d10d7..00000000 --- a/src/server/gstreamer/hostRunner.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Host GStreamer runner orchestrator. - * - * Manages the collection of active session GstManager instances, starting, - * stopping, and resetting pipelines dynamically. - */ - -import { GstManager } from "./gstManager" -import logger from "../../utils/logger" - -export class HostRunner { - private activeSessions = new Map() - private stoppingSessions = new Set() - private token: string - private serverPort: number - private onStreamError?: ( - sessionId: string, - errorType: string, - message: string, - ) => void - - constructor( - baseUrl: string, - localAuthToken: string, - onStreamError?: ( - sessionId: string, - errorType: string, - message: string, - ) => void, - ) { - this.token = localAuthToken - this.onStreamError = onStreamError - - const portMatch = baseUrl.match(/:(\d+)/) - this.serverPort = portMatch ? Number.parseInt(portMatch[1], 10) : 8000 - - logger.info(`HostRunner initialized on port: ${this.serverPort}`) - } - - public handleIncomingClientOffer( - sessionId: string, - _clientOfferSdp: string, - ): void { - if (this.activeSessions.has(sessionId)) { - logger.info("GStreamer pipeline already running, skipping restart") - return - } - - logger.info(`HostRunner launching stream for session: ${sessionId}`) - - const gst = new GstManager(sessionId) - this.activeSessions.set(sessionId, gst) - - gst.on("exit", () => { - const wasIntentional = this.stoppingSessions.has(sessionId) - this.activeSessions.delete(sessionId) - this.stoppingSessions.delete(sessionId) - if (wasIntentional) { - logger.info(`GStreamer pipeline stopped for session: ${sessionId}`) - return - } - if (this.onStreamError) { - this.onStreamError( - sessionId, - "gstreamer-exit", - "GStreamer pipeline exited unexpectedly", - ) - } - }) - - gst.on("capture-failure", (err: unknown) => { - const errMsg = err instanceof Error ? err.message : String(err) - logger.error(`Capture failure for session: ${sessionId}: ${errMsg}`) - this.activeSessions.delete(sessionId) - if (this.onStreamError) { - this.onStreamError( - sessionId, - "capture-failure", - `Capture failure: ${errMsg}`, - ) - } - }) - - void gst.start(this.token, this.serverPort) - } - - public shutdown(): void { - for (const [sessionId, manager] of this.activeSessions.entries()) { - this.stoppingSessions.add(sessionId) - manager.stop() - } - this.activeSessions.clear() - logger.info("HostRunner shutdown") - } -} diff --git a/src/server/server.ts b/src/server/server.ts index 637aeafa..8a281b86 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,172 +1,210 @@ -/** - * Routing and signaling engine setup. - */ - import type { IncomingMessage, ServerResponse } from "node:http" +import os from "node:os" import logger from "../utils/logger" -import { reinStorage } from "./api/apiState" import { - handleCreateSession, - handleGetSession, - handleDeleteSession, - handleOffer, - handleAnswer, - handleIce, - handleEvents, - handleHostStart, - handleHostStop, - handleHostStatus, - handleInputOffer, - handleGenerateToken, - handleGetToken, - handleGstSignalingGateway, - handleWhipSignalingExchange, - handleGetIp, - handleUpdateConfig, - json, -} from "./api/apiHandlers" - -interface Route { - method: string - pattern: RegExp - handler: ( + getActiveToken, + generateToken, + storeToken, + isKnownToken, +} from "./tokenStore" +import { GstManager } from "./gstreamer/gstManager" +import { WebRTCManager } from "./webRTC" +import type { InputConfig } from "./types" + +let gstManager: GstManager | null = null +let webrtcManager: WebRTCManager | null = null +let hostStatus: "stopped" | "starting" | "running" | "error" = "stopped" + +function getPrimaryIp(): string { + const interfaces = os.networkInterfaces() + for (const name of Object.keys(interfaces)) { + const ifaceList = interfaces[name] + if (!ifaceList) continue + for (const iface of ifaceList) { + if (iface.family === "IPv4" && !iface.internal) { + return iface.address + } + } + } + return "127.0.0.1" +} + +function parseJsonBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = "" + req.on("data", (chunk) => { + body += chunk + }) + req.on("end", () => { + try { + resolve(body ? JSON.parse(body) : ({} as T)) + } catch (err) { + reject(err) + } + }) + req.on("error", reject) + }) +} + +function json(res: ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body) + res.writeHead(status, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }) + res.end(payload) +} + +function requireAuth(req: IncomingMessage, res: ServerResponse): boolean { + const addr = req.socket.remoteAddress + const isLocal = + addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1" + if (isLocal) return true + + const authHeader = req.headers.authorization ?? "" + let token = authHeader.startsWith("Bearer ") + ? authHeader.slice(7).trim() + : null + + if (!token) { + const url = new URL(req.url ?? "", `http://${req.headers.host}`) + token = url.searchParams.get("token") + } + + if (!token || !isKnownToken(token)) { + json(res, 401, { error: "Unauthorized" }) + return false + } + return true +} + +// biome-ignore lint/suspicious/noExplicitAny: Vite server instance +export function attachSignalingRoutes(server: any): void { + const httpServer = server.httpServer || server + + if (!webrtcManager && httpServer) { + webrtcManager = new WebRTCManager(httpServer) + } + + if (!gstManager) { + gstManager = new GstManager() + hostStatus = "starting" + gstManager + .start() + .then(() => { + hostStatus = "running" + logger.info("GStreamer capture engine started") + }) + .catch((err) => { + logger.error(`Failed to start GStreamer capture engine: ${err}`) + hostStatus = "error" + }) + } + + const handleApiRequest = ( req: IncomingMessage, res: ServerResponse, - ...params: string[] - ) => void | Promise -} + next?: () => void, + ) => { + const pathname = new URL( + req.url ?? "", + `http://${req.headers.host ?? "localhost"}`, + ).pathname -const routes: Route[] = [ - { method: "GET", pattern: /^\/api\/host\/ip$/, handler: handleGetIp }, - { method: "POST", pattern: /^\/api\/config$/, handler: handleUpdateConfig }, - { method: "POST", pattern: /^\/api\/session$/, handler: handleCreateSession }, - { - method: "GET", - pattern: /^\/api\/session\/([^/]+)$/, - handler: (req, res, id) => handleGetSession(req, res, id), - }, - { - method: "DELETE", - pattern: /^\/api\/session\/([^/]+)$/, - handler: (req, res, id) => handleDeleteSession(req, res, id), - }, - { method: "POST", pattern: /^\/api\/webrtc\/offer$/, handler: handleOffer }, - { method: "POST", pattern: /^\/api\/webrtc\/answer$/, handler: handleAnswer }, - { method: "POST", pattern: /^\/api\/webrtc\/ice$/, handler: handleIce }, - { method: "GET", pattern: /^\/api\/webrtc\/events$/, handler: handleEvents }, - { method: "POST", pattern: /^\/api\/host\/start$/, handler: handleHostStart }, - { method: "POST", pattern: /^\/api\/host\/stop$/, handler: handleHostStop }, - { - method: "GET", - pattern: /^\/api\/host\/status$/, - handler: handleHostStatus, - }, - { - method: "POST", - pattern: /^\/api\/webrtc\/input-offer$/, - handler: handleInputOffer, - }, - { - method: "POST", - pattern: /^\/api\/auth\/token$/, - handler: handleGenerateToken, - }, - { method: "GET", pattern: /^\/api\/auth\/token$/, handler: handleGetToken }, - { - method: "POST", - pattern: /^\/api\/webrtc\/gateway$/, - handler: handleGstSignalingGateway, - }, - { - method: "POST", - pattern: /^\/api\/webrtc\/whip$/, - handler: handleWhipSignalingExchange, - }, -] - -export function attachSignalingRoutes( - server: NonNullable, -): void { - server.prependListener( - "request", - (req: IncomingMessage, res: ServerResponse) => { - const pathname = new URL( - req.url ?? "", - `http://${req.headers.host ?? "localhost"}`, - ).pathname - - if (!pathname.startsWith("/api/")) return - - const method = req.method?.toUpperCase() ?? "GET" - for (const route of routes) { - if (route.method !== method) continue - const match = pathname.match(route.pattern) - if (!match) continue - - const anyRes = res as ServerResponse & { - __handledByRein?: boolean - __reinOriginalWrite?: typeof originalWrite - } - anyRes.__handledByRein = true - - const originalSetHeader = res.setHeader.bind(res) - const originalWriteHead = res.writeHead.bind(res) - const originalWrite = res.write.bind(res) - const originalEnd = res.end.bind(res) - - anyRes.__reinOriginalWrite = originalWrite - - res.setHeader = ((...args: Parameters) => { - if (anyRes.__handledByRein && !reinStorage.getStore()) { - return res - } - if (res.headersSent) return res - return originalSetHeader(...args) - }) as typeof res.setHeader + if (!pathname.startsWith("/api/")) { + if (next) next() + return + } - res.writeHead = ((...args: unknown[]) => { - if (anyRes.__handledByRein && !reinStorage.getStore()) { - return res - } - if (res.writableEnded) return res - return (originalWriteHead as (...a: unknown[]) => ServerResponse)( - ...args, - ) - }) as typeof res.writeHead - - res.write = ((...args: unknown[]) => { - if (anyRes.__handledByRein && !reinStorage.getStore()) { - return true - } - return (originalWrite as (...a: unknown[]) => boolean)(...args) - }) as typeof res.write + if (pathname === "/api/host/start" && req.method === "POST") { + if (!requireAuth(req, res)) return + if (hostStatus === "running") { + json(res, 200, { status: hostStatus }) + return + } + hostStatus = "starting" + if (!gstManager) gstManager = new GstManager() + gstManager + .start() + .then(() => { + hostStatus = "running" + }) + .catch((err) => { + logger.error(`Failed to start GStreamer: ${err}`) + hostStatus = "error" + }) - res.end = ((...args: unknown[]) => { - if (anyRes.__handledByRein && !reinStorage.getStore()) { - return res - } - return (originalEnd as (...a: unknown[]) => ServerResponse)(...args) - }) as typeof res.end - - const params = match.slice(1) - Promise.resolve() - .then(() => - reinStorage.run(true, () => route.handler(req, res, ...params)), - ) - .catch((err) => { - logger.error(`Signaling route error: ${String(err)}`) - if (!res.headersSent) { - reinStorage.run(true, () => { - json(res, 500, { error: "Internal server error" }) - }) - } - }) + json(res, 200, { status: hostStatus }) + return + } + + if (pathname === "/api/host/stop" && req.method === "POST") { + if (!requireAuth(req, res)) return + if (gstManager) { + gstManager.stop() + } + hostStatus = "stopped" + json(res, 200, { status: hostStatus }) + return + } + + if (pathname === "/api/host/status" && req.method === "GET") { + if (!requireAuth(req, res)) return + json(res, 200, { status: hostStatus }) + return + } + + if (pathname === "/api/host/ip" && req.method === "GET") { + if (!requireAuth(req, res)) return + json(res, 200, { ip: getPrimaryIp() }) + return + } + + if (pathname === "/api/auth/token" && req.method === "POST") { + const addr = req.socket.remoteAddress + const isLocal = + addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1" + if (!isLocal) { + json(res, 403, { error: "Localhost only" }) return } - }, - ) - logger.info("Signaling HTTP routes attached") + let token = getActiveToken() + if (!token) { + token = generateToken() + storeToken(token) + } + json(res, 200, { token }) + return + } + + if (pathname === "/api/config" && req.method === "POST") { + if (!requireAuth(req, res)) return + parseJsonBody>(req) + .then((config) => { + if (webrtcManager) { + webrtcManager.updateConfig(config) + } + json(res, 200, { ok: true }) + }) + .catch((err) => { + json(res, 400, { ok: false, error: String(err) }) + }) + return + } + + json(res, 404, { error: "API endpoint not found" }) + } + + if (server.middlewares) { + server.middlewares.use(handleApiRequest) + } else if (httpServer && typeof httpServer.on === "function") { + httpServer.on("request", handleApiRequest) + } + + logger.info("Signaling HTTP routes and WebSocket attached") } -// Re-export state and event systems for other signaling servers/files -export { sessions, hostStatus, pushEvent } from "./api/apiState" +export function stopServer() { + if (webrtcManager) webrtcManager.shutdown() + if (gstManager) gstManager.stop() +} diff --git a/src/server/webRTC.ts b/src/server/webRTC.ts new file mode 100644 index 00000000..d2711c99 --- /dev/null +++ b/src/server/webRTC.ts @@ -0,0 +1,255 @@ +import { + RTCPeerConnection, + RTCRtpCodecParameters, + MediaStreamTrack, +} from "werift" +import { WebSocketServer, WebSocket } from "ws" +import dgram from "node:dgram" +import { InputHandler } from "./InputHandler" +import logger from "../utils/logger" +import type { InputMessage, InputConfig } from "./types" +import fs from "node:fs" + +interface ClientSession { + ws: WebSocket + pc: RTCPeerConnection + videoTrack: MediaStreamTrack + inputHandler: InputHandler + sessionId: string +} + +import type { IncomingMessage } from "node:http" +import type { Duplex } from "node:stream" +import type { EventEmitter } from "node:events" + +export class WebRTCManager { + private wss: WebSocketServer + private udpSocket: dgram.Socket + private clients = new Map() + + constructor(server: EventEmitter) { + this.wss = new WebSocketServer({ noServer: true }) + this.udpSocket = dgram.createSocket("udp4") + + server.on( + "upgrade", + (request: IncomingMessage, socket: Duplex, head: Buffer) => { + try { + const url = new URL( + request.url || "", + `http://${request.headers.host || "localhost"}`, + ) + if (url.pathname === "/ws") { + this.wss.handleUpgrade(request, socket, head, (ws) => { + this.wss.emit("connection", ws, request) + }) + } + } catch (_err) { + // Ignore invalid URLs or non-matching upgrades + } + }, + ) + + this.setupUdpSocket() + this.setupWebSocketServer() + } + + private setupUdpSocket() { + this.udpSocket.on("error", (err) => { + logger.error(`UDP socket error:\n${err.stack}`) + this.udpSocket.close() + }) + + this.udpSocket.on("message", (msg) => { + for (const client of this.clients.values()) { + try { + client.videoTrack.writeRtp(msg) + } catch (_err) { + // Ignore individual track write errors + } + } + }) + + this.udpSocket.bind(5004, "127.0.0.1", () => { + logger.info("UDP socket listening for RTP packets on 127.0.0.1:5004") + }) + } + + private getInitialConfig(): Partial { + try { + const configPath = "./src/server-config.json" + if (fs.existsSync(configPath)) { + const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8")) + return { + sensitivity: + typeof cfg.sensitivity === "number" ? cfg.sensitivity : 1.0, + invertScroll: + typeof cfg.invertScroll === "boolean" ? cfg.invertScroll : false, + } + } + } catch (e) { + logger.warn( + `Failed to read initial config from server-config.json: ${String(e)}`, + ) + } + return { sensitivity: 1.0, invertScroll: false } + } + + private setupWebSocketServer() { + this.wss.on("connection", async (ws) => { + const sessionId = Math.random().toString(36).substring(7) + logger.info(`Viewer connected via WebSocket: ${sessionId}`) + + const pc = new RTCPeerConnection({ + iceServers: [{ urls: "stun:stun.l.google.com:19302" }], + }) + + const videoTrack = new MediaStreamTrack({ + kind: "video", + codec: new RTCRtpCodecParameters({ + mimeType: "video/VP8", + clockRate: 90000, + payloadType: 96, + }), + }) + pc.addTrack(videoTrack) + + const inputHandler = new InputHandler( + this.getInitialConfig(), + 8, + (errorType, message) => { + logger.error(`InputHandler error: ${errorType} - ${message}`) + try { + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "error", + errorType, + message, + }), + ) + } + } catch (e) { + logger.error( + `Failed to transmit error frame to client: ${String(e)}`, + ) + } + }, + ) + + const dcUnordered = pc.createDataChannel("input-unordered", { + ordered: false, + maxRetransmits: 0, + }) + const dcOrdered = pc.createDataChannel("input-ordered", { ordered: true }) + + const handleDataMessage = (msg: Buffer | string) => { + try { + const raw = typeof msg === "string" ? msg : msg.toString() + const parsed = JSON.parse(raw) + if (parsed.type === "ping") { + const pong = JSON.stringify({ + type: "pong", + timestamp: parsed.timestamp, + }) + dcUnordered.send(pong) + dcOrdered.send(pong) + return + } + inputHandler.handleMessage(parsed as InputMessage).catch((err) => { + logger.error(`Input handler processing error: ${String(err)}`) + }) + } catch (err) { + logger.error(`Input parse error: ${String(err)}`) + } + } + + dcUnordered.onMessage.subscribe((msg) => handleDataMessage(msg)) + dcOrdered.onMessage.subscribe((msg) => handleDataMessage(msg)) + + const client: ClientSession = { + ws, + pc, + videoTrack, + inputHandler, + sessionId, + } + this.clients.set(sessionId, client) + + pc.iceConnectionStateChange.subscribe((state) => { + logger.info(`WebRTC ICE state for ${sessionId}: ${state}`) + if ( + state === "disconnected" || + state === "failed" || + state === "closed" + ) { + this.cleanupClient(sessionId) + } + }) + + pc.onIceCandidate.subscribe((candidate) => { + if (candidate) { + ws.send( + JSON.stringify({ type: "ice", candidate: candidate.toJSON() }), + ) + } + }) + + ws.on("message", async (data) => { + try { + const msg = JSON.parse(data.toString()) + if (msg.type === "answer") { + await pc.setRemoteDescription(msg.sdp) + } else if (msg.type === "ice" && msg.candidate) { + await pc.addIceCandidate(msg.candidate) + } else if (msg.type === "ping") { + ws.send(JSON.stringify({ type: "pong" })) + } + } catch (err) { + logger.error(`WebSocket message handling error: ${String(err)}`) + } + }) + + ws.on("close", () => { + this.cleanupClient(sessionId) + }) + + try { + const offer = await pc.createOffer() + await pc.setLocalDescription(offer) + ws.send(JSON.stringify({ type: "offer", sdp: offer })) + } catch (err) { + logger.error(`Failed to create offer: ${String(err)}`) + this.cleanupClient(sessionId) + } + }) + } + + private cleanupClient(sessionId: string) { + const client = this.clients.get(sessionId) + if (client) { + logger.info(`Cleaning up client session: ${sessionId}`) + try { + client.pc.close() + } catch (_e) {} + try { + client.inputHandler.destroy() + } catch (_e) {} + this.clients.delete(sessionId) + } + } + + public updateConfig(config: Partial) { + for (const client of this.clients.values()) { + client.inputHandler.updateConfig(config) + } + } + + public shutdown() { + this.wss.close() + this.udpSocket.close() + for (const sessionId of this.clients.keys()) { + this.cleanupClient(sessionId) + } + } +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts index b4184f37..d1afd798 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -52,8 +52,6 @@ if (process.env.NODE_ENV !== "production") { } // Optional: Intercept standard console.log and redirect to winston -const _originalConsoleLog = console.log -const _originalConsoleError = console.error const serialize = (a: unknown): string => typeof a === "string" ? a : JSON.stringify(a) diff --git a/vite.config.ts b/vite.config.ts index 12314476..501473c5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,14 +17,10 @@ const config = defineConfig({ { name: "rein-server", async configureServer(server) { - const httpServer = server.httpServer - if (!httpServer) return - attachSignalingRoutes(httpServer) + attachSignalingRoutes(server) }, async configurePreviewServer(server) { - const httpServer = server.httpServer - if (!httpServer) return - attachSignalingRoutes(httpServer) + attachSignalingRoutes(server) }, }, devtools(), @@ -37,7 +33,7 @@ const config = defineConfig({ }), ], ssr: { - external: ["node-datachannel", "dbus-next", "eventsource"], + external: ["dbus-next", "eventsource", "werift"], noExternal: ["tailwindcss", "@tailwindcss/postcss"], }, server: { @@ -46,7 +42,7 @@ const config = defineConfig({ }, build: { rollupOptions: { - external: ["node-datachannel"], + external: ["werift"], }, }, })