feat(media): add bounded automatic hybrid routing
This commit is contained in:
1 parent
38c270d4da
commit
96b2d4f1e6
33 files changed
+9974
-207
No files matched your search
Generated
+157
@@ -8,6 +8,8 @@
|
||||
"name": "screener",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"livekit-client": "2.22.0",
|
||||
"livekit-server-sdk": "2.17.0",
|
||||
"lucide-react": "1.31.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
@@ -30,6 +32,12 @@
|
||||
"node": ">=24.0.0 <27"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
@@ -479,6 +487,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@livekit/mutex": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
|
||||
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.50.4",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
|
||||
"integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.144.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
|
||||
@@ -783,6 +806,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-record": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1072,6 +1102,15 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
@@ -1115,6 +1154,15 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz",
|
||||
"integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
@@ -1388,6 +1436,62 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.22.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.22.0.tgz",
|
||||
"integrity": "sha512-GLtYQfRh/RsvXaOX1x609bFZ17yyKmKWDqu9JmkMKn9vIFLi2GsapRv9gT8OJO/1R2dsitrkbGmloyUfxWQsaA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.50.4",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.1.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"sdp-transform": "^2.15.0",
|
||||
"tslib": "2.8.1",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"webrtc-adapter": "9.0.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-record": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/livekit-server-sdk": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.17.0.tgz",
|
||||
"integrity": "sha512-GWNEQrUD13UwZNrmosyTVFPgeBvSU2lFKGxdA4DaUI5F4sMb1cGeep/PPEE9vvRNELJYpxWS0ImS4gQmovKUDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.1",
|
||||
"@livekit/protocol": "^1.48.0",
|
||||
"jose": "^5.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/livekit-server-sdk/node_modules/jose": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
|
||||
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/loglevel": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.31.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz",
|
||||
@@ -1559,12 +1663,37 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
|
||||
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@@ -1663,6 +1792,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.12",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
||||
@@ -1682,6 +1817,15 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"rxjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
@@ -1871,6 +2015,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
||||
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"sdp": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0",
|
||||
"npm": ">=3.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"check": "npm run typecheck && npm test && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"livekit-client": "2.22.0",
|
||||
"livekit-server-sdk": "2.17.0",
|
||||
"lucide-react": "1.31.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
Third-party software notices
|
||||
============================
|
||||
|
||||
This distribution includes:
|
||||
|
||||
- livekit-client 2.22.0 (https://github.com/livekit/client-sdk-js)
|
||||
- livekit-server-sdk 2.17.0 (https://github.com/livekit/node-sdks)
|
||||
|
||||
Both packages are licensed under the Apache License, Version 2.0. Neither
|
||||
package includes a separate NOTICE file in the installed distribution.
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,520 @@
|
||||
import type {
|
||||
ClientMessage,
|
||||
MediaRoutePhase,
|
||||
ParticipantRouteAssignment,
|
||||
ServerMessage,
|
||||
} from "../../shared/protocol";
|
||||
import { SfuPublisher, type SfuConnectionConfig } from "../sfu/publisher";
|
||||
import type { QualityProfile } from "./quality";
|
||||
import {
|
||||
MediaRouteTransition,
|
||||
type RouteOperationToken,
|
||||
type RouteUpdateInput,
|
||||
type RouteUpdateResult,
|
||||
} from "./route-transition";
|
||||
|
||||
interface HostPublisherTransport {
|
||||
connect(config: SfuConnectionConfig): Promise<boolean>;
|
||||
activate(stream: MediaStream, profile: QualityProfile): Promise<boolean>;
|
||||
deactivate(): Promise<boolean>;
|
||||
replaceStream(stream: MediaStream): Promise<boolean>;
|
||||
updateProfile(profile: QualityProfile): Promise<boolean>;
|
||||
disconnect(): Promise<void>;
|
||||
}
|
||||
|
||||
interface HostPublisherSlot {
|
||||
revision: number;
|
||||
publicationGeneration: string;
|
||||
publisher: HostPublisherTransport;
|
||||
connected: boolean;
|
||||
active: boolean;
|
||||
failed: boolean;
|
||||
acknowledgeActive: boolean;
|
||||
}
|
||||
|
||||
interface HostSfuRouteEvents {
|
||||
getStream: () => MediaStream | null;
|
||||
getProfile: () => QualityProfile;
|
||||
reconcileChildren: (childPeerIds: string[]) => void;
|
||||
send: (message: ClientMessage) => boolean;
|
||||
createPublisher?: (onDisconnected: () => void) => HostPublisherTransport;
|
||||
}
|
||||
|
||||
export class HostSfuRoute {
|
||||
private readonly route = new MediaRouteTransition();
|
||||
private pending: HostPublisherSlot | null = null;
|
||||
private active: HostPublisherSlot | null = null;
|
||||
private recovery: { revision: number; refreshed: boolean } | null = null;
|
||||
private transitionTail: Promise<void> = Promise.resolve();
|
||||
private resyncGeneration = 0;
|
||||
private resyncing = false;
|
||||
private closed = false;
|
||||
|
||||
constructor(private readonly events: HostSfuRouteEvents) {}
|
||||
|
||||
accept(update: RouteUpdateInput, acknowledge = true): RouteUpdateResult {
|
||||
if (this.closed) {
|
||||
return "stale";
|
||||
}
|
||||
const previousRevision = this.route.getRevision();
|
||||
const result = this.route.accept(update);
|
||||
if (result === "stale") {
|
||||
return result;
|
||||
}
|
||||
if (previousRevision !== update.revision) {
|
||||
this.recovery = null;
|
||||
}
|
||||
if (update.phase === "prepare") {
|
||||
if (
|
||||
this.pending &&
|
||||
(this.pending.revision !== update.revision ||
|
||||
this.pending.publicationGeneration !==
|
||||
update.assignment.sfuPublicationGeneration)
|
||||
) {
|
||||
this.clearPending();
|
||||
}
|
||||
if (update.assignment.sfuPublicationGeneration === null) {
|
||||
this.clearPending();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const token = this.route.token();
|
||||
if (token && !this.resyncing) {
|
||||
void this.queueActivation(token, acknowledge);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async resyncAuthoritative(
|
||||
update: RouteUpdateInput,
|
||||
): Promise<RouteUpdateResult> {
|
||||
const result = this.accept(update, false);
|
||||
if (result !== "stale" || this.closed) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const resyncGeneration = ++this.resyncGeneration;
|
||||
this.resyncing = true;
|
||||
this.route.reset();
|
||||
this.recovery = null;
|
||||
const pending = this.pending;
|
||||
const active = this.active;
|
||||
this.pending = null;
|
||||
this.active = null;
|
||||
if (pending) {
|
||||
pending.failed = true;
|
||||
}
|
||||
if (active) {
|
||||
active.failed = true;
|
||||
}
|
||||
const accepted = this.route.accept(update);
|
||||
this.events.reconcileChildren([]);
|
||||
|
||||
await this.queueTransition(async () => {
|
||||
if (active?.active) {
|
||||
await active.publisher.deactivate().catch(() => false);
|
||||
}
|
||||
if (active) {
|
||||
await disconnectPublisher(active.publisher);
|
||||
}
|
||||
if (pending && pending !== active) {
|
||||
await disconnectPublisher(pending.publisher);
|
||||
}
|
||||
});
|
||||
|
||||
if (this.closed || this.resyncGeneration !== resyncGeneration) {
|
||||
return "stale";
|
||||
}
|
||||
this.resyncing = false;
|
||||
const token = this.route.token();
|
||||
if (token && this.route.getPhase() === "active") {
|
||||
await this.queueActivation(token, false);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
async acceptConfig(
|
||||
message: Extract<ServerMessage, { type: "sfu-config" }>,
|
||||
): Promise<void> {
|
||||
if (this.closed || !this.route.acceptsConfig(message.revision)) {
|
||||
return;
|
||||
}
|
||||
const token = this.route.token();
|
||||
const assignment = this.route.getPlannedAssignment();
|
||||
const phase = this.route.getPhase();
|
||||
const publicationGeneration = assignment?.sfuPublicationGeneration;
|
||||
if (!token || !phase || !assignment || !publicationGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.active?.publicationGeneration === publicationGeneration) {
|
||||
this.active.revision = message.revision;
|
||||
if (phase === "prepare" && this.active.connected) {
|
||||
this.ready(message.revision, "prepare");
|
||||
} else if (phase === "active" && this.active.active) {
|
||||
await this.queueActivation(token, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.pending?.revision === message.revision &&
|
||||
this.pending.publicationGeneration === publicationGeneration &&
|
||||
!this.pending.failed
|
||||
) {
|
||||
if (this.pending.connected && phase === "prepare") {
|
||||
this.ready(message.revision, "prepare");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearPending();
|
||||
let slot: HostPublisherSlot;
|
||||
const publisher =
|
||||
this.events.createPublisher?.(() => this.handleFailure(slot)) ??
|
||||
new SfuPublisher({ onDisconnected: () => this.handleFailure(slot) });
|
||||
slot = {
|
||||
revision: message.revision,
|
||||
publicationGeneration,
|
||||
publisher,
|
||||
connected: false,
|
||||
active: false,
|
||||
failed: false,
|
||||
acknowledgeActive: false,
|
||||
};
|
||||
this.pending = slot;
|
||||
try {
|
||||
const connected = await publisher.connect({
|
||||
url: message.url,
|
||||
token: message.token,
|
||||
});
|
||||
if (!connected) {
|
||||
if (this.pending === slot && this.route.owns(token)) {
|
||||
this.handleFailure(slot);
|
||||
}
|
||||
await disconnectPublisher(publisher);
|
||||
return;
|
||||
}
|
||||
if (this.pending !== slot || !this.route.owns(token)) {
|
||||
await disconnectPublisher(publisher);
|
||||
return;
|
||||
}
|
||||
slot.connected = true;
|
||||
if (phase === "prepare") {
|
||||
this.ready(message.revision, "prepare");
|
||||
} else if (!this.resyncing) {
|
||||
await this.queueActivation(token, true);
|
||||
}
|
||||
} catch {
|
||||
await disconnectPublisher(publisher);
|
||||
this.handleFailure(slot);
|
||||
}
|
||||
}
|
||||
|
||||
updateProfile(profile: QualityProfile): Promise<boolean> {
|
||||
return this.active?.active
|
||||
? this.active.publisher.updateProfile(profile).catch(() => false)
|
||||
: Promise.resolve(true);
|
||||
}
|
||||
|
||||
replaceStream(stream: MediaStream): Promise<boolean> {
|
||||
return this.active
|
||||
? this.active.publisher.replaceStream(stream).catch(() => false)
|
||||
: Promise.resolve(true);
|
||||
}
|
||||
|
||||
async failActivePublisher(): Promise<void> {
|
||||
await this.queueTransition(async () => {
|
||||
const slot = this.active;
|
||||
if (!slot) {
|
||||
return;
|
||||
}
|
||||
this.active = null;
|
||||
await disconnectPublisher(slot.publisher);
|
||||
this.handleFailure(slot);
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
this.resyncGeneration += 1;
|
||||
this.resyncing = false;
|
||||
this.route.reset();
|
||||
this.recovery = null;
|
||||
await this.queueTransition(async () => {
|
||||
const pending = this.pending;
|
||||
const active = this.active;
|
||||
this.pending = null;
|
||||
this.active = null;
|
||||
if (active?.active) {
|
||||
await active.publisher.deactivate().catch(() => false);
|
||||
}
|
||||
await Promise.all([
|
||||
pending ? disconnectPublisher(pending.publisher) : undefined,
|
||||
active && active !== pending
|
||||
? disconnectPublisher(active.publisher)
|
||||
: undefined,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private queueActivation(
|
||||
token: RouteOperationToken,
|
||||
acknowledge: boolean,
|
||||
): Promise<void> {
|
||||
return this.queueTransition(async () => {
|
||||
if (this.closed || this.resyncing || !this.route.owns(token, "active")) {
|
||||
return;
|
||||
}
|
||||
const assignment = this.route.getActiveAssignment();
|
||||
if (assignment) {
|
||||
await this.activate(assignment, token, acknowledge);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private queueTransition(work: () => Promise<void>): Promise<void> {
|
||||
const next = this.transitionTail.then(work, work);
|
||||
this.transitionTail = next.catch(() => undefined);
|
||||
return this.transitionTail;
|
||||
}
|
||||
|
||||
private async activate(
|
||||
assignment: ParticipantRouteAssignment,
|
||||
token: RouteOperationToken,
|
||||
acknowledge: boolean,
|
||||
): Promise<void> {
|
||||
const publicationGeneration = assignment.sfuPublicationGeneration;
|
||||
if (publicationGeneration === null) {
|
||||
this.clearPending();
|
||||
await this.retireActive();
|
||||
if (!this.route.owns(token, "active")) {
|
||||
return;
|
||||
}
|
||||
this.events.reconcileChildren(assignment.childPeerIds);
|
||||
this.commitMedia(token, acknowledge);
|
||||
return;
|
||||
}
|
||||
|
||||
this.events.reconcileChildren(assignment.childPeerIds);
|
||||
if (
|
||||
this.active &&
|
||||
!this.active.failed &&
|
||||
this.active.publicationGeneration === publicationGeneration
|
||||
) {
|
||||
this.active.revision = token.revision;
|
||||
this.clearPending();
|
||||
if (this.active.active) {
|
||||
this.commitMedia(token, acknowledge);
|
||||
} else {
|
||||
this.active.acknowledgeActive ||= acknowledge;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.pending;
|
||||
if (
|
||||
!pending ||
|
||||
pending.revision !== token.revision ||
|
||||
pending.publicationGeneration !== publicationGeneration ||
|
||||
!pending.connected
|
||||
) {
|
||||
await this.retireActive();
|
||||
if (acknowledge && this.route.owns(token, "active")) {
|
||||
this.requestRecovery(token.revision);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.retireActive(pending);
|
||||
if (!this.route.owns(token, "active")) {
|
||||
this.clearPending(pending);
|
||||
return;
|
||||
}
|
||||
this.pending = null;
|
||||
this.active = pending;
|
||||
pending.acknowledgeActive ||= acknowledge;
|
||||
const publishedStream = this.events.getStream();
|
||||
const publishedProfile = this.events.getProfile();
|
||||
if (!publishedStream) {
|
||||
await disconnectPublisher(pending.publisher);
|
||||
this.handleFailure(pending);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activated = await pending.publisher.activate(
|
||||
publishedStream,
|
||||
publishedProfile,
|
||||
);
|
||||
const activeToken = this.currentActiveToken(pending);
|
||||
if (!activated || !activeToken) {
|
||||
if (this.active === pending) {
|
||||
this.active = null;
|
||||
}
|
||||
await disconnectPublisher(pending.publisher);
|
||||
if (!activated && activeToken) {
|
||||
this.handleFailure(pending);
|
||||
}
|
||||
return;
|
||||
}
|
||||
pending.active = true;
|
||||
|
||||
const latestStream = this.events.getStream();
|
||||
if (
|
||||
latestStream &&
|
||||
latestStream !== publishedStream &&
|
||||
!(await pending.publisher.replaceStream(latestStream))
|
||||
) {
|
||||
await disconnectPublisher(pending.publisher);
|
||||
this.handleFailure(pending);
|
||||
return;
|
||||
}
|
||||
const latestProfile = this.events.getProfile();
|
||||
if (
|
||||
latestProfile !== publishedProfile &&
|
||||
!(await pending.publisher.updateProfile(latestProfile))
|
||||
) {
|
||||
await disconnectPublisher(pending.publisher);
|
||||
this.handleFailure(pending);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
await disconnectPublisher(pending.publisher);
|
||||
this.handleFailure(pending);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeToken = this.currentActiveToken(pending);
|
||||
if (!activeToken) {
|
||||
if (this.active === pending) {
|
||||
this.active = null;
|
||||
}
|
||||
await disconnectPublisher(pending.publisher);
|
||||
return;
|
||||
}
|
||||
if (!this.route.owns(token, "active")) {
|
||||
pending.acknowledgeActive = false;
|
||||
return;
|
||||
}
|
||||
const acknowledgeActive = pending.acknowledgeActive;
|
||||
pending.acknowledgeActive = false;
|
||||
this.commitMedia(activeToken, acknowledgeActive);
|
||||
}
|
||||
|
||||
private currentActiveToken(
|
||||
slot: HostPublisherSlot,
|
||||
): RouteOperationToken | null {
|
||||
const token = this.route.token();
|
||||
const assignment = this.route.getActiveAssignment();
|
||||
if (
|
||||
this.active !== slot ||
|
||||
!token ||
|
||||
this.route.getPhase() !== "active" ||
|
||||
assignment?.sfuPublicationGeneration !== slot.publicationGeneration
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
private commitMedia(token: RouteOperationToken, acknowledge: boolean): void {
|
||||
if (!this.route.markMediaActive(token)) {
|
||||
return;
|
||||
}
|
||||
this.recovery = null;
|
||||
if (acknowledge) {
|
||||
this.ready(token.revision, "active");
|
||||
}
|
||||
}
|
||||
|
||||
private clearPending(expected?: HostPublisherSlot): void {
|
||||
if (!this.pending || (expected && this.pending !== expected)) {
|
||||
return;
|
||||
}
|
||||
const pending = this.pending;
|
||||
this.pending = null;
|
||||
void disconnectPublisher(pending.publisher);
|
||||
}
|
||||
|
||||
private async retireActive(except?: HostPublisherSlot): Promise<void> {
|
||||
if (!this.active || this.active === except) {
|
||||
return;
|
||||
}
|
||||
const active = this.active;
|
||||
this.active = null;
|
||||
if (active.active) {
|
||||
await active.publisher.deactivate().catch(() => false);
|
||||
}
|
||||
await disconnectPublisher(active.publisher);
|
||||
}
|
||||
|
||||
private handleFailure(slot: HostPublisherSlot): void {
|
||||
if (this.closed || slot.failed) {
|
||||
return;
|
||||
}
|
||||
const wasActive = this.active === slot;
|
||||
slot.failed = true;
|
||||
if (this.pending === slot) {
|
||||
this.pending = null;
|
||||
}
|
||||
if (wasActive) {
|
||||
this.active = null;
|
||||
}
|
||||
|
||||
const assignment = this.route.getPlannedAssignment();
|
||||
const revision = this.route.getRevision();
|
||||
const phase = this.route.getPhase();
|
||||
const matchesPlannedRoute =
|
||||
revision === slot.revision &&
|
||||
assignment?.sfuPublicationGeneration === slot.publicationGeneration;
|
||||
if (revision === null || phase === null) {
|
||||
return;
|
||||
}
|
||||
if (phase === "prepare") {
|
||||
if (matchesPlannedRoute || wasActive) {
|
||||
this.routeFailed(revision, "prepare");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!matchesPlannedRoute) {
|
||||
return;
|
||||
}
|
||||
this.requestRecovery(revision);
|
||||
}
|
||||
|
||||
private requestRecovery(revision: number): void {
|
||||
if (!this.recovery || this.recovery.revision !== revision) {
|
||||
this.recovery = { revision, refreshed: false };
|
||||
}
|
||||
if (!this.recovery.refreshed) {
|
||||
this.recovery.refreshed = true;
|
||||
if (this.events.send({ type: "refresh-sfu", revision })) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.routeFailed(revision, "active");
|
||||
}
|
||||
|
||||
private routeFailed(revision: number, phase: MediaRoutePhase): void {
|
||||
this.events.send({
|
||||
type: "route-failed",
|
||||
revision,
|
||||
phase,
|
||||
connectionId: null,
|
||||
});
|
||||
}
|
||||
|
||||
private ready(revision: number, phase: MediaRoutePhase): void {
|
||||
this.events.send({ type: "route-ready", revision, phase });
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectPublisher(
|
||||
publisher: HostPublisherTransport,
|
||||
): Promise<void> {
|
||||
await publisher.disconnect().catch(() => undefined);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ClientMessage } from "../../shared/protocol";
|
||||
|
||||
export interface BrowserRelayEnvironment {
|
||||
userAgent: string;
|
||||
userAgentDataMobile?: boolean;
|
||||
maxTouchPoints: number;
|
||||
}
|
||||
|
||||
type RelayCapacityMessage = Extract<
|
||||
ClientMessage,
|
||||
{ type: "relay-capacity" }
|
||||
>;
|
||||
|
||||
export function detectBrowserRelayCapacity(
|
||||
environment: BrowserRelayEnvironment,
|
||||
): 0 | 1 {
|
||||
if (
|
||||
environment.userAgentDataMobile === true ||
|
||||
/Android|iPhone|iPad|iPod|Mobile/i.test(environment.userAgent) ||
|
||||
(/Macintosh/i.test(environment.userAgent) &&
|
||||
environment.maxTouchPoints > 1)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function relayCapacityMessageForBrowser(
|
||||
peerAssisted: boolean,
|
||||
environment?: BrowserRelayEnvironment,
|
||||
): RelayCapacityMessage | null {
|
||||
if (!peerAssisted) {
|
||||
return null;
|
||||
}
|
||||
const browser = environment ?? currentBrowserEnvironment();
|
||||
return {
|
||||
type: "relay-capacity",
|
||||
downstreamEdges: detectBrowserRelayCapacity(browser),
|
||||
};
|
||||
}
|
||||
|
||||
function currentBrowserEnvironment(): BrowserRelayEnvironment {
|
||||
const browserNavigator = navigator as Navigator & {
|
||||
userAgentData?: { mobile?: boolean };
|
||||
};
|
||||
return {
|
||||
userAgent: browserNavigator.userAgent,
|
||||
userAgentDataMobile: browserNavigator.userAgentData?.mobile,
|
||||
maxTouchPoints: browserNavigator.maxTouchPoints,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type {
|
||||
ClientMessage,
|
||||
MediaRoutePhase,
|
||||
ParticipantRouteAssignment,
|
||||
} from "../../shared/protocol";
|
||||
|
||||
export interface RouteUpdateInput {
|
||||
revision: number;
|
||||
phase: MediaRoutePhase;
|
||||
assignment: ParticipantRouteAssignment;
|
||||
}
|
||||
|
||||
export interface RouteOperationToken {
|
||||
revision: number;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export type RouteUpdateResult = "accepted" | "duplicate" | "stale";
|
||||
|
||||
export class MediaRouteTransition {
|
||||
private revision = -1;
|
||||
private phase: MediaRoutePhase | null = null;
|
||||
private plannedAssignment: ParticipantRouteAssignment | null = null;
|
||||
private activeAssignment: ParticipantRouteAssignment | null = null;
|
||||
private mediaAssignment: ParticipantRouteAssignment | null = null;
|
||||
private generation = 0;
|
||||
|
||||
accept(update: RouteUpdateInput): RouteUpdateResult {
|
||||
if (update.revision < this.revision) {
|
||||
return "stale";
|
||||
}
|
||||
if (update.revision === this.revision) {
|
||||
if (
|
||||
!this.plannedAssignment ||
|
||||
!sameAssignment(this.plannedAssignment, update.assignment) ||
|
||||
(this.phase === "active" && update.phase === "prepare")
|
||||
) {
|
||||
return "stale";
|
||||
}
|
||||
if (this.phase === update.phase) {
|
||||
return "duplicate";
|
||||
}
|
||||
}
|
||||
|
||||
this.revision = update.revision;
|
||||
this.phase = update.phase;
|
||||
this.plannedAssignment = cloneAssignment(update.assignment);
|
||||
if (update.phase === "active") {
|
||||
this.activeAssignment = cloneAssignment(update.assignment);
|
||||
}
|
||||
this.generation += 1;
|
||||
return "accepted";
|
||||
}
|
||||
|
||||
acceptsConfig(revision: number): boolean {
|
||||
return revision === this.revision && this.phase !== null;
|
||||
}
|
||||
|
||||
token(): RouteOperationToken | null {
|
||||
if (this.revision < 0 || this.phase === null) {
|
||||
return null;
|
||||
}
|
||||
return { revision: this.revision, generation: this.generation };
|
||||
}
|
||||
|
||||
owns(token: RouteOperationToken, phase?: MediaRoutePhase): boolean {
|
||||
return (
|
||||
token.revision === this.revision &&
|
||||
token.generation === this.generation &&
|
||||
(phase === undefined || this.phase === phase)
|
||||
);
|
||||
}
|
||||
|
||||
markMediaActive(token: RouteOperationToken): boolean {
|
||||
if (!this.owns(token, "active") || !this.activeAssignment) {
|
||||
return false;
|
||||
}
|
||||
this.mediaAssignment = cloneAssignment(this.activeAssignment);
|
||||
return true;
|
||||
}
|
||||
|
||||
getRevision(): number | null {
|
||||
return this.revision < 0 ? null : this.revision;
|
||||
}
|
||||
|
||||
getPhase(): MediaRoutePhase | null {
|
||||
return this.phase;
|
||||
}
|
||||
|
||||
getPlannedAssignment(): ParticipantRouteAssignment | null {
|
||||
return this.plannedAssignment
|
||||
? cloneAssignment(this.plannedAssignment)
|
||||
: null;
|
||||
}
|
||||
|
||||
getActiveAssignment(): ParticipantRouteAssignment | null {
|
||||
return this.activeAssignment ? cloneAssignment(this.activeAssignment) : null;
|
||||
}
|
||||
|
||||
getMediaAssignment(): ParticipantRouteAssignment | null {
|
||||
return this.mediaAssignment ? cloneAssignment(this.mediaAssignment) : null;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.revision = -1;
|
||||
this.phase = null;
|
||||
this.plannedAssignment = null;
|
||||
this.activeAssignment = null;
|
||||
this.mediaAssignment = null;
|
||||
this.generation += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function reportActivePeerRouteFailure(
|
||||
route: MediaRouteTransition,
|
||||
parentPeerId: string,
|
||||
connectionId: string,
|
||||
send: (message: ClientMessage) => boolean,
|
||||
): boolean {
|
||||
const assignment = route.getActiveAssignment();
|
||||
const revision = route.getRevision();
|
||||
if (
|
||||
route.getPhase() !== "active" ||
|
||||
revision === null ||
|
||||
assignment?.upstream.kind !== "peer" ||
|
||||
assignment.upstream.peerId !== parentPeerId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return send({
|
||||
type: "route-failed",
|
||||
revision,
|
||||
phase: "active",
|
||||
connectionId,
|
||||
});
|
||||
}
|
||||
|
||||
function sameAssignment(
|
||||
left: ParticipantRouteAssignment,
|
||||
right: ParticipantRouteAssignment,
|
||||
): boolean {
|
||||
return (
|
||||
left.upstream.kind === right.upstream.kind &&
|
||||
(left.upstream.kind !== "peer" ||
|
||||
(right.upstream.kind === "peer" &&
|
||||
left.upstream.peerId === right.upstream.peerId)) &&
|
||||
left.sfuPublicationGeneration === right.sfuPublicationGeneration &&
|
||||
left.childPeerIds.length === right.childPeerIds.length &&
|
||||
left.childPeerIds.every((peerId, index) => peerId === right.childPeerIds[index])
|
||||
);
|
||||
}
|
||||
|
||||
function cloneAssignment(
|
||||
assignment: ParticipantRouteAssignment,
|
||||
): ParticipantRouteAssignment {
|
||||
return {
|
||||
upstream:
|
||||
assignment.upstream.kind === "peer"
|
||||
? { kind: "peer", peerId: assignment.upstream.peerId }
|
||||
: { kind: assignment.upstream.kind },
|
||||
childPeerIds: [...assignment.childPeerIds],
|
||||
sfuPublicationGeneration: assignment.sfuPublicationGeneration,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ServerMessage } from "../../shared/protocol";
|
||||
|
||||
const AUTHORITY_MESSAGE_TYPES = new Set<ServerMessage["type"]>([
|
||||
"authenticated",
|
||||
"ice-config",
|
||||
"host-status",
|
||||
"sharing-stopped",
|
||||
"room-closed",
|
||||
"error",
|
||||
]);
|
||||
|
||||
export class ViewerMessageAuthority {
|
||||
private generation = 0;
|
||||
|
||||
tokenFor(message: ServerMessage): number {
|
||||
if (AUTHORITY_MESSAGE_TYPES.has(message.type)) {
|
||||
this.generation += 1;
|
||||
}
|
||||
return this.generation;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.generation += 1;
|
||||
}
|
||||
|
||||
owns(token: number): boolean {
|
||||
return token === this.generation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import type {
|
||||
ClientMessage,
|
||||
MediaRoutePhase,
|
||||
ParticipantRouteAssignment,
|
||||
ServerMessage,
|
||||
} from "../../shared/protocol";
|
||||
import { SfuSubscriber } from "../sfu/subscriber";
|
||||
import type { SfuConnectionConfig } from "../sfu/publisher";
|
||||
import {
|
||||
MediaRouteTransition,
|
||||
reportActivePeerRouteFailure,
|
||||
type RouteOperationToken,
|
||||
type RouteUpdateInput,
|
||||
type RouteUpdateResult,
|
||||
} from "./route-transition";
|
||||
|
||||
interface ViewerSubscriberTransport {
|
||||
connect(config: SfuConnectionConfig): Promise<boolean>;
|
||||
activate(): boolean;
|
||||
deactivate(): boolean;
|
||||
disconnect(): Promise<void>;
|
||||
}
|
||||
|
||||
interface ViewerSubscriberSlot {
|
||||
revision: number;
|
||||
subscriber: ViewerSubscriberTransport;
|
||||
connected: boolean;
|
||||
activated: boolean;
|
||||
failed: boolean;
|
||||
activationToken: RouteOperationToken | null;
|
||||
}
|
||||
|
||||
interface ViewerSfuRouteEvents {
|
||||
activatePeer: (
|
||||
assignment: ParticipantRouteAssignment,
|
||||
) => void | Promise<void>;
|
||||
preparePeer?: (assignment: ParticipantRouteAssignment | null) => void;
|
||||
resetMedia?: () => void;
|
||||
reconcileSfuChildren: (childPeerIds: string[]) => void;
|
||||
onSfuStream: (
|
||||
stream: MediaStream,
|
||||
assignment: ParticipantRouteAssignment,
|
||||
initialVideoStream: boolean,
|
||||
) => void;
|
||||
send: (message: ClientMessage) => boolean;
|
||||
createSubscriber?: (
|
||||
events: {
|
||||
onStream: (stream: MediaStream | null) => void;
|
||||
onDisconnected: () => void;
|
||||
},
|
||||
) => ViewerSubscriberTransport;
|
||||
}
|
||||
|
||||
export class ViewerSfuRoute {
|
||||
private readonly route = new MediaRouteTransition();
|
||||
private pending: ViewerSubscriberSlot | null = null;
|
||||
private active: ViewerSubscriberSlot | null = null;
|
||||
private recovery: { revision: number; refreshed: boolean } | null = null;
|
||||
private transitionTail: Promise<void> = Promise.resolve();
|
||||
private resyncGeneration = 0;
|
||||
private resyncing = false;
|
||||
private closed = false;
|
||||
|
||||
constructor(private readonly events: ViewerSfuRouteEvents) {}
|
||||
|
||||
accept(update: RouteUpdateInput, acknowledge = true): RouteUpdateResult {
|
||||
if (this.closed) {
|
||||
return "stale";
|
||||
}
|
||||
const previousRevision = this.route.getRevision();
|
||||
const result = this.route.accept(update);
|
||||
if (result === "stale") {
|
||||
return result;
|
||||
}
|
||||
if (previousRevision !== update.revision) {
|
||||
this.recovery = null;
|
||||
}
|
||||
if (update.phase === "prepare") {
|
||||
if (this.pending?.revision !== update.revision) {
|
||||
this.clearPending();
|
||||
}
|
||||
if (update.assignment.upstream.kind !== "sfu") {
|
||||
this.clearPending();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const token = this.route.token();
|
||||
if (!token) {
|
||||
return result;
|
||||
}
|
||||
this.preparePeer(update.assignment);
|
||||
if (this.resyncing) {
|
||||
return result;
|
||||
}
|
||||
void this.queueActiveRoute(token, acknowledge);
|
||||
return result;
|
||||
}
|
||||
|
||||
async resyncAuthoritative(
|
||||
update: RouteUpdateInput,
|
||||
): Promise<RouteUpdateResult> {
|
||||
const result = this.accept(update, false);
|
||||
if (result !== "stale" || this.closed) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const resyncGeneration = ++this.resyncGeneration;
|
||||
this.resyncing = true;
|
||||
this.route.reset();
|
||||
this.recovery = null;
|
||||
const pending = this.pending;
|
||||
const active = this.active;
|
||||
this.pending = null;
|
||||
this.active = null;
|
||||
if (pending) {
|
||||
pending.failed = true;
|
||||
}
|
||||
if (active) {
|
||||
active.failed = true;
|
||||
}
|
||||
const accepted = this.route.accept(update);
|
||||
this.preparePeer(update.assignment);
|
||||
this.events.resetMedia?.();
|
||||
|
||||
await this.queueTransition(async () => {
|
||||
if (active?.activated) {
|
||||
try {
|
||||
active.subscriber.deactivate();
|
||||
} catch {
|
||||
// Disconnect remains the fail-closed cleanup path.
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
await disconnectSubscriber(active.subscriber);
|
||||
}
|
||||
if (pending && pending !== active) {
|
||||
await disconnectSubscriber(pending.subscriber);
|
||||
}
|
||||
});
|
||||
|
||||
if (this.closed || this.resyncGeneration !== resyncGeneration) {
|
||||
return "stale";
|
||||
}
|
||||
this.resyncing = false;
|
||||
const token = this.route.token();
|
||||
if (token && this.route.getPhase() === "active") {
|
||||
await this.queueActiveRoute(token, false);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
async acceptConfig(
|
||||
message: Extract<ServerMessage, { type: "sfu-config" }>,
|
||||
): Promise<void> {
|
||||
if (this.closed || !this.route.acceptsConfig(message.revision)) {
|
||||
return;
|
||||
}
|
||||
const token = this.route.token();
|
||||
const assignment = this.route.getPlannedAssignment();
|
||||
const phase = this.route.getPhase();
|
||||
if (!token || !phase || assignment?.upstream.kind !== "sfu") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.active?.revision === message.revision && this.active.activated) {
|
||||
if (phase === "prepare") {
|
||||
this.ready(message.revision, "prepare");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.pending?.revision === message.revision && !this.pending.failed) {
|
||||
if (this.pending.connected && phase === "prepare") {
|
||||
this.ready(message.revision, "prepare");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearPending();
|
||||
let slot: ViewerSubscriberSlot;
|
||||
const subscriberEvents = {
|
||||
onStream: (stream: MediaStream | null) => {
|
||||
if (stream) {
|
||||
this.handleStream(slot, stream);
|
||||
}
|
||||
},
|
||||
onDisconnected: () => this.handleFailure(slot),
|
||||
};
|
||||
const subscriber =
|
||||
this.events.createSubscriber?.(subscriberEvents) ??
|
||||
new SfuSubscriber(subscriberEvents);
|
||||
slot = {
|
||||
revision: message.revision,
|
||||
subscriber,
|
||||
connected: false,
|
||||
activated: false,
|
||||
failed: false,
|
||||
activationToken: null,
|
||||
};
|
||||
this.pending = slot;
|
||||
try {
|
||||
const connected = await subscriber.connect({
|
||||
url: message.url,
|
||||
token: message.token,
|
||||
});
|
||||
if (!connected) {
|
||||
if (this.pending === slot && this.route.owns(token)) {
|
||||
this.handleFailure(slot);
|
||||
}
|
||||
await disconnectSubscriber(subscriber);
|
||||
return;
|
||||
}
|
||||
if (this.pending !== slot || !this.route.owns(token)) {
|
||||
await disconnectSubscriber(subscriber);
|
||||
return;
|
||||
}
|
||||
slot.connected = true;
|
||||
if (phase === "prepare") {
|
||||
this.ready(message.revision, "prepare");
|
||||
} else if (!this.resyncing) {
|
||||
await this.queueActiveRoute(token, true);
|
||||
}
|
||||
} catch {
|
||||
await disconnectSubscriber(subscriber);
|
||||
this.handleFailure(slot);
|
||||
}
|
||||
}
|
||||
|
||||
reportPeerFailure(parentPeerId: string, connectionId: string): boolean {
|
||||
return reportActivePeerRouteFailure(
|
||||
this.route,
|
||||
parentPeerId,
|
||||
connectionId,
|
||||
this.events.send,
|
||||
);
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
this.resyncGeneration += 1;
|
||||
this.resyncing = false;
|
||||
this.route.reset();
|
||||
this.recovery = null;
|
||||
this.events.preparePeer?.(null);
|
||||
await this.queueTransition(async () => {
|
||||
const pending = this.pending;
|
||||
const active = this.active;
|
||||
this.pending = null;
|
||||
this.active = null;
|
||||
if (active?.activated) {
|
||||
try {
|
||||
active.subscriber.deactivate();
|
||||
} catch {
|
||||
// Disconnect remains the fail-closed cleanup path.
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
pending ? disconnectSubscriber(pending.subscriber) : undefined,
|
||||
active && active !== pending
|
||||
? disconnectSubscriber(active.subscriber)
|
||||
: undefined,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private preparePeer(assignment: ParticipantRouteAssignment): void {
|
||||
this.events.preparePeer?.(
|
||||
assignment.upstream.kind === "peer" ? assignment : null,
|
||||
);
|
||||
}
|
||||
|
||||
private queueActiveRoute(
|
||||
token: RouteOperationToken,
|
||||
acknowledge: boolean,
|
||||
): Promise<void> {
|
||||
return this.queueTransition(async () => {
|
||||
if (this.closed || this.resyncing || !this.route.owns(token, "active")) {
|
||||
return;
|
||||
}
|
||||
const assignment = this.route.getActiveAssignment();
|
||||
if (!assignment) {
|
||||
return;
|
||||
}
|
||||
if (assignment.upstream.kind === "sfu") {
|
||||
this.events.reconcileSfuChildren(assignment.childPeerIds);
|
||||
const mediaAssignment = this.route.getMediaAssignment();
|
||||
if (
|
||||
mediaAssignment?.upstream.kind === "sfu" &&
|
||||
this.active?.activated &&
|
||||
this.pending?.revision !== token.revision
|
||||
) {
|
||||
this.clearPending();
|
||||
this.active.revision = token.revision;
|
||||
this.commitMedia(token, acknowledge);
|
||||
return;
|
||||
}
|
||||
await this.activateSfu(token, acknowledge);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearPending();
|
||||
if (this.active) {
|
||||
await this.retireActive();
|
||||
}
|
||||
if (!this.route.owns(token, "active")) {
|
||||
return;
|
||||
}
|
||||
await this.events.activatePeer(assignment);
|
||||
if (!this.route.owns(token, "active")) {
|
||||
return;
|
||||
}
|
||||
this.commitMedia(token, acknowledge);
|
||||
});
|
||||
}
|
||||
|
||||
private queueTransition(work: () => Promise<void>): Promise<void> {
|
||||
const next = this.transitionTail.then(work, work);
|
||||
this.transitionTail = next.catch(() => undefined);
|
||||
return this.transitionTail;
|
||||
}
|
||||
|
||||
private async activateSfu(
|
||||
token: RouteOperationToken,
|
||||
acknowledge: boolean,
|
||||
): Promise<void> {
|
||||
if (this.active?.revision === token.revision && this.active.activated) {
|
||||
return;
|
||||
}
|
||||
const pending = this.pending;
|
||||
if (!pending || pending.revision !== token.revision || !pending.connected) {
|
||||
if (acknowledge && this.route.owns(token, "active")) {
|
||||
this.requestRecovery(token.revision);
|
||||
}
|
||||
return;
|
||||
}
|
||||
pending.activationToken = token;
|
||||
try {
|
||||
if (!pending.subscriber.activate()) {
|
||||
await disconnectSubscriber(pending.subscriber);
|
||||
this.handleFailure(pending);
|
||||
return;
|
||||
}
|
||||
pending.activated = true;
|
||||
} catch {
|
||||
await disconnectSubscriber(pending.subscriber);
|
||||
this.handleFailure(pending);
|
||||
}
|
||||
}
|
||||
|
||||
private handleStream(slot: ViewerSubscriberSlot, stream: MediaStream): void {
|
||||
if (this.closed || slot.failed || !slot.activated) {
|
||||
return;
|
||||
}
|
||||
if (this.active === slot) {
|
||||
const assignment = this.route.getMediaAssignment();
|
||||
if (assignment?.upstream.kind === "sfu") {
|
||||
this.events.onSfuStream(stream, assignment, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const token = slot.activationToken;
|
||||
const assignment = this.route.getActiveAssignment();
|
||||
if (
|
||||
this.pending !== slot ||
|
||||
!token ||
|
||||
!this.route.owns(token, "active") ||
|
||||
assignment?.upstream.kind !== "sfu"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pending = null;
|
||||
const previous = this.active;
|
||||
this.active = slot;
|
||||
if (!this.route.markMediaActive(token)) {
|
||||
this.active = previous;
|
||||
void disconnectSubscriber(slot.subscriber);
|
||||
return;
|
||||
}
|
||||
this.recovery = null;
|
||||
this.events.onSfuStream(stream, assignment, true);
|
||||
if (previous && previous !== slot) {
|
||||
try {
|
||||
previous.subscriber.deactivate();
|
||||
} catch {
|
||||
// Disconnect remains the fail-closed cleanup path.
|
||||
}
|
||||
void disconnectSubscriber(previous.subscriber);
|
||||
}
|
||||
this.ready(token.revision, "active");
|
||||
}
|
||||
|
||||
private commitMedia(token: RouteOperationToken, acknowledge: boolean): void {
|
||||
if (!this.route.markMediaActive(token)) {
|
||||
return;
|
||||
}
|
||||
this.recovery = null;
|
||||
if (acknowledge) {
|
||||
this.ready(token.revision, "active");
|
||||
}
|
||||
}
|
||||
|
||||
private clearPending(): void {
|
||||
if (!this.pending) {
|
||||
return;
|
||||
}
|
||||
const pending = this.pending;
|
||||
this.pending = null;
|
||||
void disconnectSubscriber(pending.subscriber);
|
||||
}
|
||||
|
||||
private async retireActive(): Promise<void> {
|
||||
if (!this.active) {
|
||||
return;
|
||||
}
|
||||
const active = this.active;
|
||||
this.active = null;
|
||||
if (active.activated) {
|
||||
try {
|
||||
active.subscriber.deactivate();
|
||||
} catch {
|
||||
// Disconnect remains the fail-closed cleanup path.
|
||||
}
|
||||
}
|
||||
await disconnectSubscriber(active.subscriber);
|
||||
}
|
||||
|
||||
private handleFailure(slot: ViewerSubscriberSlot): void {
|
||||
if (this.closed || slot.failed) {
|
||||
return;
|
||||
}
|
||||
const wasActive = this.active === slot;
|
||||
slot.failed = true;
|
||||
if (this.pending === slot) {
|
||||
this.pending = null;
|
||||
}
|
||||
if (wasActive) {
|
||||
this.active = null;
|
||||
}
|
||||
|
||||
const assignment = this.route.getPlannedAssignment();
|
||||
const revision = this.route.getRevision();
|
||||
const phase = this.route.getPhase();
|
||||
const matchesPlannedRoute =
|
||||
revision === slot.revision && assignment?.upstream.kind === "sfu";
|
||||
if (revision === null || phase === null) {
|
||||
return;
|
||||
}
|
||||
if (phase === "prepare") {
|
||||
if (matchesPlannedRoute || wasActive) {
|
||||
this.routeFailed(revision, "prepare");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!matchesPlannedRoute) {
|
||||
return;
|
||||
}
|
||||
this.requestRecovery(revision);
|
||||
}
|
||||
|
||||
private requestRecovery(revision: number): void {
|
||||
if (!this.recovery || this.recovery.revision !== revision) {
|
||||
this.recovery = { revision, refreshed: false };
|
||||
}
|
||||
if (!this.recovery.refreshed) {
|
||||
this.recovery.refreshed = true;
|
||||
if (this.events.send({ type: "refresh-sfu", revision })) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.routeFailed(revision, "active");
|
||||
}
|
||||
|
||||
private routeFailed(revision: number, phase: MediaRoutePhase): void {
|
||||
this.events.send({
|
||||
type: "route-failed",
|
||||
revision,
|
||||
phase,
|
||||
connectionId: null,
|
||||
});
|
||||
}
|
||||
|
||||
private ready(revision: number, phase: MediaRoutePhase): void {
|
||||
this.events.send({ type: "route-ready", revision, phase });
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectSubscriber(
|
||||
subscriber: ViewerSubscriberTransport,
|
||||
): Promise<void> {
|
||||
await subscriber.disconnect().catch(() => undefined);
|
||||
}
|
||||
+102
-23
@@ -25,6 +25,7 @@ import {
|
||||
} from "../components/StatusBadge";
|
||||
import { StatsGrid } from "../components/StatsGrid";
|
||||
import { ApiError, createRoom } from "../lib/api";
|
||||
import { createOpaqueId } from "../lib/opaque-id";
|
||||
import {
|
||||
clearHostRoom,
|
||||
getStableClientId,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
setVideoPaused,
|
||||
type QualityProfileId,
|
||||
} from "../media/quality";
|
||||
import { HostSfuRoute } from "../media/host-sfu-route";
|
||||
import type {
|
||||
PeerSnapshot,
|
||||
SignalConnectionState,
|
||||
@@ -85,6 +87,7 @@ function closeAbandonedRoom(room: CreateRoomResponse): void {
|
||||
role: "host",
|
||||
token: room.hostToken,
|
||||
clientId: getStableClientId("host", room.roomId),
|
||||
shareGeneration: createOpaqueId(),
|
||||
},
|
||||
{
|
||||
onMessage: () => undefined,
|
||||
@@ -113,6 +116,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const [qualityId, setQualityId] = useState<QualityProfileId>(
|
||||
DEFAULT_QUALITY_PROFILE_ID,
|
||||
);
|
||||
const shareGenerationRef = useRef<string | null>(null);
|
||||
const [phase, setPhase] = useState<HostPhase>("idle");
|
||||
const [signalStatus, setSignalStatus] =
|
||||
useState<SignalConnectionState>("offline");
|
||||
@@ -143,6 +147,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const qualityIdRef = useRef<QualityProfileId>(DEFAULT_QUALITY_PROFILE_ID);
|
||||
const picturePausedRef = useRef(false);
|
||||
const retiringStreamRef = useRef<MediaStream | null>(null);
|
||||
const hostSfuRouteRef = useRef<HostSfuRoute | null>(null);
|
||||
|
||||
const viewers = useMemo(
|
||||
() => Array.from(peerSnapshots.values()),
|
||||
@@ -164,6 +169,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
signalRef.current?.stop();
|
||||
peersRef.current.forEach((peer) => peer.dispose());
|
||||
peersRef.current.clear();
|
||||
void hostSfuRouteRef.current?.disconnect();
|
||||
hostSfuRouteRef.current = null;
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
retiringStreamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
@@ -179,21 +186,59 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
function ensureHostSfuRoute(generation: number): HostSfuRoute {
|
||||
const existing = hostSfuRouteRef.current;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
let route: HostSfuRoute;
|
||||
route = new HostSfuRoute({
|
||||
getStream: () => streamRef.current,
|
||||
getProfile: () => QUALITY_PROFILES[qualityIdRef.current],
|
||||
reconcileChildren: (childPeerIds) => {
|
||||
if (
|
||||
isCurrentGeneration(generation) &&
|
||||
hostSfuRouteRef.current === route
|
||||
) {
|
||||
reconcilePeerAssistedChildren(childPeerIds, generation);
|
||||
}
|
||||
},
|
||||
send: (message) =>
|
||||
isCurrentGeneration(generation) && hostSfuRouteRef.current === route
|
||||
? signalRef.current?.send(message) === true
|
||||
: false,
|
||||
});
|
||||
hostSfuRouteRef.current = route;
|
||||
return route;
|
||||
}
|
||||
|
||||
function clearHostSfuRoute(): void {
|
||||
const route = hostSfuRouteRef.current;
|
||||
hostSfuRouteRef.current = null;
|
||||
void route?.disconnect();
|
||||
}
|
||||
|
||||
function disposeResources(notifyServer: boolean): void {
|
||||
sourceSwitchRef.current = null;
|
||||
qualityChangeRef.current = null;
|
||||
const signal = signalRef.current;
|
||||
if (signal) {
|
||||
if (notifyServer) {
|
||||
signal.send({ type: "stop-sharing" });
|
||||
const shareGeneration = shareGenerationRef.current;
|
||||
if (shareGeneration) {
|
||||
signal.send({ type: "stop-sharing", shareGeneration });
|
||||
}
|
||||
}
|
||||
// Never carry a terminal message into a later authentication: the next
|
||||
// sharing generation may already be reusing this room.
|
||||
signal.stop();
|
||||
}
|
||||
signalRef.current = null;
|
||||
shareGenerationRef.current = null;
|
||||
peersRef.current.forEach((peer) => peer.dispose());
|
||||
peersRef.current.clear();
|
||||
void hostSfuRouteRef.current?.disconnect();
|
||||
hostSfuRouteRef.current = null;
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
retiringStreamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
@@ -311,17 +356,20 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
qualityProfileId: nextId,
|
||||
});
|
||||
}
|
||||
const results = await Promise.all(
|
||||
[...peersRef.current.values()].map((peer) => peer.updateProfile(profile)),
|
||||
);
|
||||
const [results, sfuUpdated] = await Promise.all([
|
||||
Promise.all(
|
||||
[...peersRef.current.values()].map((peer) => peer.updateProfile(profile)),
|
||||
),
|
||||
hostSfuRouteRef.current?.updateProfile(profile) ?? Promise.resolve(true),
|
||||
]);
|
||||
if (
|
||||
isCurrentGeneration(generation) &&
|
||||
qualityChangeRef.current === token
|
||||
) {
|
||||
const failed = results.filter((updated) => !updated).length;
|
||||
setNotice(
|
||||
failed > 0
|
||||
? `画质已切换,${failed} 位观众未能应用新的发送参数`
|
||||
failed > 0 || !sfuUpdated
|
||||
? "画质已切换,但部分观看连接未能应用新参数"
|
||||
: `画质已切换为 ${profile.label}`,
|
||||
);
|
||||
}
|
||||
@@ -520,13 +568,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
type: "set-quality-profile",
|
||||
qualityProfileId: qualityIdRef.current,
|
||||
});
|
||||
reconcilePeerAssistedChildren(
|
||||
message.mediaAssignment.childPeerIds,
|
||||
generation,
|
||||
void ensureHostSfuRoute(generation).resyncAuthoritative(
|
||||
{
|
||||
revision: message.routeRevision,
|
||||
phase: "active",
|
||||
assignment: message.routeAssignment,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
peerAssistedRef.current = false;
|
||||
clearHostSfuRoute();
|
||||
const currentViewerIds = new Set(message.viewerPeerIds);
|
||||
for (const peerId of peersRef.current.keys()) {
|
||||
if (!currentViewerIds.has(peerId)) {
|
||||
@@ -535,8 +587,20 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "media-assignment") {
|
||||
if (message.type === "route-update") {
|
||||
if (peerAssistedRef.current) {
|
||||
ensureHostSfuRoute(generation).accept(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "sfu-config") {
|
||||
if (peerAssistedRef.current) {
|
||||
void ensureHostSfuRoute(generation).acceptConfig(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "media-assignment") {
|
||||
if (peerAssistedRef.current && !hostSfuRouteRef.current) {
|
||||
reconcilePeerAssistedChildren(
|
||||
message.mediaAssignment.childPeerIds,
|
||||
generation,
|
||||
@@ -621,8 +685,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
return;
|
||||
}
|
||||
const generation = generationRef.current + 1;
|
||||
const shareGeneration = createOpaqueId();
|
||||
generationRef.current = generation;
|
||||
activeGenerationRef.current = generation;
|
||||
shareGenerationRef.current = shareGeneration;
|
||||
setNotice(null);
|
||||
setCopied(false);
|
||||
setPhase("starting");
|
||||
@@ -636,6 +702,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
return;
|
||||
}
|
||||
activeGenerationRef.current = null;
|
||||
shareGenerationRef.current = null;
|
||||
setNotice(readableError(error));
|
||||
setPhase("error");
|
||||
return;
|
||||
@@ -681,6 +748,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
role: "host",
|
||||
token: activeRoom.hostToken,
|
||||
clientId: getStableClientId("host", activeRoom.roomId),
|
||||
shareGeneration,
|
||||
},
|
||||
{
|
||||
onStatus: (status) => {
|
||||
@@ -819,19 +887,23 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
watchCaptureEnd(captured, generation);
|
||||
|
||||
try {
|
||||
const replacements = await Promise.all(
|
||||
[...peersRef.current.entries()].map(async ([peerId, peer]) => {
|
||||
try {
|
||||
return {
|
||||
peerId,
|
||||
peer,
|
||||
replaced: await peer.replaceStream(captured),
|
||||
};
|
||||
} catch {
|
||||
return { peerId, peer, replaced: false };
|
||||
}
|
||||
}),
|
||||
);
|
||||
const activeSfuRoute = hostSfuRouteRef.current;
|
||||
const [replacements, sfuReplaced] = await Promise.all([
|
||||
Promise.all(
|
||||
[...peersRef.current.entries()].map(async ([peerId, peer]) => {
|
||||
try {
|
||||
return {
|
||||
peerId,
|
||||
peer,
|
||||
replaced: await peer.replaceStream(captured),
|
||||
};
|
||||
} catch {
|
||||
return { peerId, peer, replaced: false };
|
||||
}
|
||||
}),
|
||||
),
|
||||
activeSfuRoute?.replaceStream(captured) ?? Promise.resolve(true),
|
||||
]);
|
||||
|
||||
if (
|
||||
!isCurrentGeneration(generation) ||
|
||||
@@ -848,6 +920,13 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
failedPeerIds.push(peerId);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!sfuReplaced &&
|
||||
activeSfuRoute &&
|
||||
hostSfuRouteRef.current === activeSfuRoute
|
||||
) {
|
||||
await activeSfuRoute.failActivePublisher();
|
||||
}
|
||||
|
||||
previousStream.getTracks().forEach((track) => track.stop());
|
||||
if (retiringStreamRef.current === previousStream) {
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
QUALITY_PROFILES,
|
||||
type QualityProfileId,
|
||||
} from "../media/quality";
|
||||
import { relayCapacityMessageForBrowser } from "../media/relay-capability";
|
||||
import { ViewerMessageAuthority } from "../media/viewer-message-authority";
|
||||
import { ViewerSfuRoute } from "../media/viewer-sfu-route";
|
||||
import type {
|
||||
PeerSnapshot,
|
||||
SignalConnectionState,
|
||||
@@ -75,6 +78,12 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
childPeerIds: [],
|
||||
};
|
||||
let viewerRelay: ViewerRelay | null = null;
|
||||
let viewerSfuRoute: ViewerSfuRoute | null = null;
|
||||
let preparedParentPeerId: string | null = null;
|
||||
let preparedParentSignals: Array<
|
||||
Extract<ServerMessage, { type: "signal" }>
|
||||
> = [];
|
||||
const messageAuthority = new ViewerMessageAuthority();
|
||||
|
||||
const signal = new SignalingClient(
|
||||
{
|
||||
@@ -90,6 +99,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
},
|
||||
onProtocolError: (message) => {
|
||||
if (active) {
|
||||
messageAuthority.invalidate();
|
||||
setStatusText(message);
|
||||
}
|
||||
},
|
||||
@@ -97,11 +107,14 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
messageAuthority.invalidate();
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
setStatusText(message);
|
||||
},
|
||||
onAccessRequired: () => {
|
||||
if (active) {
|
||||
messageAuthority.invalidate();
|
||||
onAuthorizationRequired();
|
||||
}
|
||||
},
|
||||
@@ -109,7 +122,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
void handleMessage(message);
|
||||
void handleMessage(message, messageAuthority.tokenFor(message));
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -147,6 +160,122 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
return viewerRelay;
|
||||
}
|
||||
|
||||
function ensureViewerSfuRoute(): ViewerSfuRoute {
|
||||
if (viewerSfuRoute) {
|
||||
return viewerSfuRoute;
|
||||
}
|
||||
let route: ViewerSfuRoute;
|
||||
route = new ViewerSfuRoute({
|
||||
activatePeer: (assignment) => {
|
||||
if (!active || viewerSfuRoute !== route) {
|
||||
return;
|
||||
}
|
||||
applyMediaAssignment({
|
||||
parentPeerId:
|
||||
assignment.upstream.kind === "peer"
|
||||
? assignment.upstream.peerId
|
||||
: null,
|
||||
childPeerIds: assignment.childPeerIds,
|
||||
});
|
||||
if (assignment.upstream.kind === "peer") {
|
||||
return drainPreparedParentSignals(assignment.upstream.peerId);
|
||||
}
|
||||
},
|
||||
preparePeer: (assignment) => {
|
||||
if (!active || viewerSfuRoute !== route) {
|
||||
return;
|
||||
}
|
||||
prepareParent(
|
||||
assignment?.upstream.kind === "peer"
|
||||
? assignment.upstream.peerId
|
||||
: null,
|
||||
);
|
||||
},
|
||||
resetMedia: () => {
|
||||
if (!active || viewerSfuRoute !== route) {
|
||||
return;
|
||||
}
|
||||
currentAssignment = { parentPeerId: null, childPeerIds: [] };
|
||||
clearPeerState();
|
||||
viewerRelay?.setChild(null);
|
||||
},
|
||||
reconcileSfuChildren: (childPeerIds) => {
|
||||
if (!active || viewerSfuRoute !== route) {
|
||||
return;
|
||||
}
|
||||
currentAssignment = limitMediaAssignment(
|
||||
{
|
||||
parentPeerId: currentAssignment.parentPeerId,
|
||||
childPeerIds,
|
||||
},
|
||||
MAX_VIEWER_MEDIA_CHILDREN,
|
||||
);
|
||||
ensureViewerRelay()?.setChild(
|
||||
currentAssignment.childPeerIds[0] ?? null,
|
||||
);
|
||||
},
|
||||
onSfuStream: (nextStream, assignment, initialVideoStream) => {
|
||||
if (!active || viewerSfuRoute !== route) {
|
||||
return;
|
||||
}
|
||||
currentAssignment = limitMediaAssignment(
|
||||
{ parentPeerId: null, childPeerIds: assignment.childPeerIds },
|
||||
MAX_VIEWER_MEDIA_CHILDREN,
|
||||
);
|
||||
const relay = ensureViewerRelay();
|
||||
relay?.setChild(currentAssignment.childPeerIds[0] ?? null);
|
||||
relay?.setStream(nextStream);
|
||||
setRemoteStream(nextStream);
|
||||
setStatusText("正在播放");
|
||||
if (initialVideoStream) {
|
||||
peerRef.current?.dispose();
|
||||
peerRef.current = null;
|
||||
setPeerSnapshot(null);
|
||||
}
|
||||
},
|
||||
send: (message) =>
|
||||
active && viewerSfuRoute === route ? signal.send(message) : false,
|
||||
});
|
||||
viewerSfuRoute = route;
|
||||
return route;
|
||||
}
|
||||
|
||||
function clearViewerSfuRoute(): void {
|
||||
const route = viewerSfuRoute;
|
||||
viewerSfuRoute = null;
|
||||
prepareParent(null);
|
||||
void route?.disconnect();
|
||||
}
|
||||
|
||||
function prepareParent(parentPeerId: string | null): void {
|
||||
if (preparedParentPeerId === parentPeerId) {
|
||||
return;
|
||||
}
|
||||
preparedParentPeerId = parentPeerId;
|
||||
preparedParentSignals = [];
|
||||
}
|
||||
|
||||
async function drainPreparedParentSignals(
|
||||
parentPeerId: string,
|
||||
): Promise<void> {
|
||||
while (
|
||||
active &&
|
||||
peerAssisted &&
|
||||
preparedParentPeerId === parentPeerId
|
||||
) {
|
||||
const message = preparedParentSignals.shift();
|
||||
if (!message) {
|
||||
preparedParentPeerId = null;
|
||||
return;
|
||||
}
|
||||
const peer = ensurePeer();
|
||||
if (!peer) {
|
||||
return;
|
||||
}
|
||||
await peer.acceptSignal(message.fromPeerId, message.payload);
|
||||
}
|
||||
}
|
||||
|
||||
function clearUpstreamState(): void {
|
||||
peerRef.current?.dispose();
|
||||
peerRef.current = null;
|
||||
@@ -232,6 +361,8 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
}
|
||||
}
|
||||
},
|
||||
onRecoveryExhausted: (parentPeerId, connectionId) =>
|
||||
viewerSfuRoute?.reportPeerFailure(parentPeerId, connectionId) ?? true,
|
||||
},
|
||||
forceRelay,
|
||||
);
|
||||
@@ -239,17 +370,25 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
return peer;
|
||||
}
|
||||
|
||||
async function handleMessage(message: ServerMessage): Promise<void> {
|
||||
async function handleMessage(
|
||||
message: ServerMessage,
|
||||
authorityToken: number,
|
||||
): Promise<void> {
|
||||
if (message.type === "authenticated") {
|
||||
const nextPeerAssisted =
|
||||
"mediaMode" in message && message.mediaMode === "peer-assisted";
|
||||
if (!nextPeerAssisted && peerAssisted) {
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
viewerRelay?.dispose();
|
||||
viewerRelay = null;
|
||||
currentAssignment = { parentPeerId: null, childPeerIds: [] };
|
||||
}
|
||||
peerAssisted = nextPeerAssisted;
|
||||
const relayCapacity = relayCapacityMessageForBrowser(nextPeerAssisted);
|
||||
if (relayCapacity) {
|
||||
signal.send(relayCapacity);
|
||||
}
|
||||
currentIceConfig = message.iceConfig;
|
||||
currentHostOnline = message.hostOnline;
|
||||
setRelayAvailable(message.iceConfig.relayAvailable);
|
||||
@@ -259,7 +398,16 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
void viewerRelay?.updateProfile(
|
||||
QUALITY_PROFILES[currentQualityProfileId],
|
||||
);
|
||||
applyMediaAssignment(message.mediaAssignment);
|
||||
await ensureViewerSfuRoute().resyncAuthoritative(
|
||||
{
|
||||
revision: message.routeRevision,
|
||||
phase: "active",
|
||||
assignment: message.routeAssignment,
|
||||
},
|
||||
);
|
||||
if (!active || !messageAuthority.owns(authorityToken)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!message.hostOnline &&
|
||||
@@ -298,8 +446,20 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "media-assignment") {
|
||||
if (message.type === "route-update") {
|
||||
if (peerAssisted) {
|
||||
ensureViewerSfuRoute().accept(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "sfu-config") {
|
||||
if (peerAssisted) {
|
||||
await ensureViewerSfuRoute().acceptConfig(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "media-assignment") {
|
||||
if (peerAssisted && !viewerSfuRoute) {
|
||||
applyMediaAssignment(message.mediaAssignment);
|
||||
}
|
||||
return;
|
||||
@@ -314,6 +474,13 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
return;
|
||||
}
|
||||
if (message.type === "signal") {
|
||||
if (
|
||||
peerAssisted &&
|
||||
preparedParentPeerId === message.fromPeerId
|
||||
) {
|
||||
preparedParentSignals.push(message);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
peerAssisted &&
|
||||
currentAssignment.childPeerIds[0] === message.fromPeerId
|
||||
@@ -367,12 +534,14 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
}
|
||||
if (message.type === "sharing-stopped") {
|
||||
currentHostOnline = false;
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
setHostOnline(false);
|
||||
setStatusText("分享已停止,等待分享者再次开始");
|
||||
return;
|
||||
}
|
||||
if (message.type === "room-closed") {
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
setStatusText(message.reason === "expired" ? "房间已过期" : "分享已结束");
|
||||
signal.stop();
|
||||
@@ -392,6 +561,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
"ROOM_FULL",
|
||||
].includes(message.code)
|
||||
) {
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
}
|
||||
setStatusText(message.message);
|
||||
@@ -402,6 +572,10 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
return () => {
|
||||
active = false;
|
||||
signal.stop();
|
||||
void viewerSfuRoute?.disconnect();
|
||||
viewerSfuRoute = null;
|
||||
preparedParentPeerId = null;
|
||||
preparedParentSignals = [];
|
||||
peerRef.current?.dispose();
|
||||
peerRef.current = null;
|
||||
viewerRelay?.dispose();
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import type {
|
||||
LocalTrackPublication,
|
||||
Room,
|
||||
Track,
|
||||
TrackPublishOptions,
|
||||
} from "livekit-client";
|
||||
|
||||
import {
|
||||
configureVideoSender,
|
||||
type QualityProfile,
|
||||
} from "../media/quality";
|
||||
|
||||
export interface SfuConnectionConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface PublisherEvents {
|
||||
onDisconnected?: () => void;
|
||||
}
|
||||
|
||||
interface PublishedTrack {
|
||||
publication: LocalTrackPublication;
|
||||
rawTrack: MediaStreamTrack;
|
||||
}
|
||||
|
||||
type LiveKit = typeof import("livekit-client");
|
||||
type PublisherState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "prepared"
|
||||
| "active"
|
||||
| "disconnected";
|
||||
|
||||
const roomDisconnects = new WeakMap<Room, Promise<void>>();
|
||||
|
||||
export class SfuPublisher {
|
||||
private room: Room | null = null;
|
||||
private sdk: LiveKit | null = null;
|
||||
private video: PublishedTrack | null = null;
|
||||
private audio: PublishedTrack | null = null;
|
||||
private profile: QualityProfile | null = null;
|
||||
private state: PublisherState = "idle";
|
||||
private generation = 0;
|
||||
private operationTail: Promise<void> = Promise.resolve();
|
||||
private terminalNotified = false;
|
||||
|
||||
constructor(private readonly events: PublisherEvents = {}) {}
|
||||
|
||||
async connect(config: SfuConnectionConfig): Promise<boolean> {
|
||||
if (this.state !== "idle") {
|
||||
throw new Error("SFU publisher cannot be connected twice");
|
||||
}
|
||||
|
||||
const generation = ++this.generation;
|
||||
this.state = "connecting";
|
||||
|
||||
try {
|
||||
const sdk = await import("livekit-client");
|
||||
if (!this.ownsGeneration(generation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const room = new sdk.Room();
|
||||
this.room = room;
|
||||
this.sdk = sdk;
|
||||
room.on(sdk.RoomEvent.Disconnected, () => {
|
||||
if (this.owns(room, generation)) {
|
||||
const notify = this.state !== "connecting";
|
||||
this.invalidate();
|
||||
if (notify) {
|
||||
this.notifyTerminalDisconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await room.connect(config.url, config.token, { autoSubscribe: false });
|
||||
if (!this.owns(room, generation)) {
|
||||
await safeDisconnect(room);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.state = "prepared";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!this.ownsGeneration(generation)) {
|
||||
return false;
|
||||
}
|
||||
const room = this.invalidate();
|
||||
if (room) {
|
||||
await safeDisconnect(room);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
activate(stream: MediaStream, profile: QualityProfile): Promise<boolean> {
|
||||
const generation = this.generation;
|
||||
return this.enqueue(async () => {
|
||||
const room = this.requireOwnedRoom(generation, "prepared");
|
||||
if (!room) {
|
||||
return false;
|
||||
}
|
||||
const sdk = this.sdk;
|
||||
if (!sdk) {
|
||||
throw new Error("SFU publisher SDK is unavailable");
|
||||
}
|
||||
|
||||
const videoTrack = requiredVideoTrack(stream);
|
||||
const audioTrack = stream.getAudioTracks()[0] ?? null;
|
||||
|
||||
try {
|
||||
const video = await publishTrack(
|
||||
room,
|
||||
videoTrack,
|
||||
sdk.Track.Source.ScreenShare,
|
||||
videoPublishOptions(profile),
|
||||
);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let audio: PublishedTrack | null = null;
|
||||
if (audioTrack) {
|
||||
audio = await publishTrack(
|
||||
room,
|
||||
audioTrack,
|
||||
sdk.Track.Source.ScreenShareAudio,
|
||||
{ dtx: false },
|
||||
);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.video = video;
|
||||
this.audio = audio;
|
||||
this.profile = profile;
|
||||
this.state = "active";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (this.owns(room, generation)) {
|
||||
await this.failClosed(room, generation);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deactivate(): Promise<boolean> {
|
||||
const generation = this.generation;
|
||||
return this.enqueue(async () => {
|
||||
const room = this.requireOwnedRoom(generation, ["prepared", "active"]);
|
||||
if (!room) {
|
||||
return false;
|
||||
}
|
||||
if (this.state === "prepared") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const video = this.video;
|
||||
const audio = this.audio;
|
||||
try {
|
||||
if (audio) {
|
||||
await unpublishTrack(room, audio);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (video) {
|
||||
await unpublishTrack(room, video);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.owns(room, generation)) {
|
||||
await this.failClosed(room, generation);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.video = null;
|
||||
this.audio = null;
|
||||
this.profile = null;
|
||||
this.state = "prepared";
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
replaceStream(stream: MediaStream): Promise<boolean> {
|
||||
const generation = this.generation;
|
||||
return this.enqueue(async () => {
|
||||
const room = this.requireOwnedRoom(generation, "active");
|
||||
if (!room) {
|
||||
return false;
|
||||
}
|
||||
const sdk = this.sdk;
|
||||
const previousVideo = this.video;
|
||||
if (!sdk || !previousVideo) {
|
||||
throw new Error("SFU publisher has no active video publication");
|
||||
}
|
||||
|
||||
const nextVideoTrack = requiredVideoTrack(stream);
|
||||
const nextAudioTrack = stream.getAudioTracks()[0] ?? null;
|
||||
const previousAudio = this.audio;
|
||||
let videoReplaceAttempted = false;
|
||||
let audioReplaceAttempted = false;
|
||||
|
||||
try {
|
||||
videoReplaceAttempted = true;
|
||||
await replacePublishedTrack(previousVideo, nextVideoTrack);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previousAudio && nextAudioTrack) {
|
||||
audioReplaceAttempted = true;
|
||||
await replacePublishedTrack(previousAudio, nextAudioTrack);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
} else if (previousAudio) {
|
||||
await unpublishTrack(room, previousAudio);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
this.audio = null;
|
||||
} else if (nextAudioTrack) {
|
||||
const nextAudio = await publishTrack(
|
||||
room,
|
||||
nextAudioTrack,
|
||||
sdk.Track.Source.ScreenShareAudio,
|
||||
{ dtx: false },
|
||||
);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
this.audio = nextAudio;
|
||||
}
|
||||
|
||||
previousVideo.rawTrack = nextVideoTrack;
|
||||
if (previousAudio && nextAudioTrack) {
|
||||
previousAudio.rawTrack = nextAudioTrack;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A rejected publish/unpublish may have changed server state without
|
||||
// returning enough ownership information to undo it safely.
|
||||
if (
|
||||
(previousAudio === null && nextAudioTrack !== null) ||
|
||||
(previousAudio !== null && nextAudioTrack === null)
|
||||
) {
|
||||
await this.failClosed(room, generation);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
if (audioReplaceAttempted && previousAudio) {
|
||||
await replacePublishedTrack(previousAudio, previousAudio.rawTrack);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (videoReplaceAttempted) {
|
||||
await replacePublishedTrack(previousVideo, previousVideo.rawTrack);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (rollbackError) {
|
||||
if (this.owns(room, generation)) {
|
||||
await this.failClosed(room, generation);
|
||||
}
|
||||
throw rollbackError;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateProfile(profile: QualityProfile): Promise<boolean> {
|
||||
const generation = this.generation;
|
||||
return this.enqueue(async () => {
|
||||
const room = this.requireOwnedRoom(generation, "active");
|
||||
if (!room) {
|
||||
return false;
|
||||
}
|
||||
const video = this.video;
|
||||
const previousProfile = this.profile;
|
||||
if (!video || !previousProfile) {
|
||||
throw new Error("SFU publisher has no active video publication");
|
||||
}
|
||||
|
||||
try {
|
||||
await configurePublishedVideo(video, profile);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
this.profile = profile;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await configurePublishedVideo(video, previousProfile);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
} catch (rollbackError) {
|
||||
if (this.owns(room, generation)) {
|
||||
await this.failClosed(room, generation);
|
||||
}
|
||||
throw rollbackError;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
const room = this.invalidate();
|
||||
if (room) {
|
||||
await safeDisconnect(room);
|
||||
}
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.operationTail.then(operation, operation);
|
||||
this.operationTail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private requireOwnedRoom(
|
||||
generation: number,
|
||||
expectedState: PublisherState | PublisherState[],
|
||||
): Room | null {
|
||||
if (!this.ownsGeneration(generation)) {
|
||||
return null;
|
||||
}
|
||||
const expected = Array.isArray(expectedState)
|
||||
? expectedState
|
||||
: [expectedState];
|
||||
if (!expected.includes(this.state)) {
|
||||
throw new Error(`SFU publisher is ${this.state}, expected ${expected.join(" or ")}`);
|
||||
}
|
||||
if (!this.room) {
|
||||
throw new Error("SFU publisher room is unavailable");
|
||||
}
|
||||
return this.room;
|
||||
}
|
||||
|
||||
private ownsGeneration(generation: number): boolean {
|
||||
return this.generation === generation && this.state !== "disconnected";
|
||||
}
|
||||
|
||||
private owns(room: Room, generation: number): boolean {
|
||||
return this.room === room && this.ownsGeneration(generation);
|
||||
}
|
||||
|
||||
private invalidate(): Room | null {
|
||||
if (this.state === "disconnected") {
|
||||
return null;
|
||||
}
|
||||
++this.generation;
|
||||
this.state = "disconnected";
|
||||
const room = this.room;
|
||||
this.room = null;
|
||||
this.sdk = null;
|
||||
this.video = null;
|
||||
this.audio = null;
|
||||
this.profile = null;
|
||||
return room;
|
||||
}
|
||||
|
||||
private async failClosed(room: Room, generation: number): Promise<void> {
|
||||
if (!this.owns(room, generation)) {
|
||||
return;
|
||||
}
|
||||
this.invalidate();
|
||||
await safeDisconnect(room);
|
||||
this.notifyTerminalDisconnect();
|
||||
}
|
||||
|
||||
private notifyTerminalDisconnect(): void {
|
||||
if (this.terminalNotified) {
|
||||
return;
|
||||
}
|
||||
this.terminalNotified = true;
|
||||
this.events.onDisconnected?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function publishTrack(
|
||||
room: Room,
|
||||
rawTrack: MediaStreamTrack,
|
||||
source: Track.Source,
|
||||
options: TrackPublishOptions,
|
||||
): Promise<PublishedTrack> {
|
||||
const publication = await room.localParticipant.publishTrack(rawTrack, {
|
||||
source,
|
||||
...options,
|
||||
});
|
||||
if (!publication.track) {
|
||||
throw new Error("SFU did not retain the published track");
|
||||
}
|
||||
return { publication, rawTrack };
|
||||
}
|
||||
|
||||
async function replacePublishedTrack(
|
||||
published: PublishedTrack,
|
||||
nextTrack: MediaStreamTrack,
|
||||
): Promise<void> {
|
||||
const localTrack = published.publication.track;
|
||||
if (!localTrack) {
|
||||
throw new Error("SFU publication has no local track");
|
||||
}
|
||||
await localTrack.replaceTrack(nextTrack);
|
||||
}
|
||||
|
||||
async function unpublishTrack(room: Room, published: PublishedTrack): Promise<void> {
|
||||
const localTrack = published.publication.track;
|
||||
if (!localTrack) {
|
||||
throw new Error("SFU publication has no local track");
|
||||
}
|
||||
await room.localParticipant.unpublishTrack(localTrack, false);
|
||||
}
|
||||
|
||||
async function configurePublishedVideo(
|
||||
published: PublishedTrack,
|
||||
profile: QualityProfile,
|
||||
): Promise<void> {
|
||||
const videoTrack = published.publication.videoTrack;
|
||||
const sender = videoTrack?.sender;
|
||||
if (!sender) {
|
||||
throw new Error("SFU video publication has no RTP sender");
|
||||
}
|
||||
await configureVideoSender(sender, profile);
|
||||
videoTrack.publishOptions = {
|
||||
...videoTrack.publishOptions,
|
||||
...videoPublishOptions(profile),
|
||||
};
|
||||
}
|
||||
|
||||
function videoPublishOptions(profile: QualityProfile): TrackPublishOptions {
|
||||
return {
|
||||
simulcast: false,
|
||||
screenShareEncoding: {
|
||||
maxBitrate: profile.maxBitrate,
|
||||
maxFramerate: profile.frameRate,
|
||||
},
|
||||
degradationPreference: "balanced",
|
||||
};
|
||||
}
|
||||
|
||||
async function safeDisconnect(room: Room): Promise<void> {
|
||||
const existing = roomDisconnects.get(room);
|
||||
if (existing) {
|
||||
await existing;
|
||||
return;
|
||||
}
|
||||
const disconnecting = room.disconnect(false).catch(() => undefined);
|
||||
roomDisconnects.set(room, disconnecting);
|
||||
await disconnecting;
|
||||
}
|
||||
|
||||
function requiredVideoTrack(stream: MediaStream): MediaStreamTrack {
|
||||
const track = stream.getVideoTracks()[0];
|
||||
if (!track) {
|
||||
throw new Error("屏幕共享没有视频轨道");
|
||||
}
|
||||
return track;
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import type {
|
||||
RemoteParticipant,
|
||||
RemoteTrack,
|
||||
RemoteTrackPublication,
|
||||
Room,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
|
||||
import type { SfuConnectionConfig } from "./publisher";
|
||||
|
||||
interface SubscriberEvents {
|
||||
onStream: (stream: MediaStream | null) => void;
|
||||
onDisconnected?: () => void;
|
||||
}
|
||||
|
||||
interface SubscribedTrack {
|
||||
sid: string;
|
||||
track: MediaStreamTrack;
|
||||
}
|
||||
|
||||
type SubscriberState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "prepared"
|
||||
| "active"
|
||||
| "disconnected";
|
||||
type LiveKit = typeof import("livekit-client");
|
||||
|
||||
const HOST_IDENTITY = "host";
|
||||
const roomDisconnects = new WeakMap<Room, Promise<void>>();
|
||||
|
||||
export class SfuSubscriber {
|
||||
private room: Room | null = null;
|
||||
private sdk: LiveKit | null = null;
|
||||
private video: SubscribedTrack | null = null;
|
||||
private audio: SubscribedTrack | null = null;
|
||||
private readonly desiredTrackSids = new Set<string>();
|
||||
private state: SubscriberState = "idle";
|
||||
private generation = 0;
|
||||
private terminalNotified = false;
|
||||
|
||||
constructor(private readonly events: SubscriberEvents) {}
|
||||
|
||||
async connect(config: SfuConnectionConfig): Promise<boolean> {
|
||||
if (this.state !== "idle") {
|
||||
throw new Error("SFU subscriber cannot be connected twice");
|
||||
}
|
||||
|
||||
const generation = ++this.generation;
|
||||
this.state = "connecting";
|
||||
|
||||
try {
|
||||
const sdk = await import("livekit-client");
|
||||
if (!this.ownsGeneration(generation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const room = new sdk.Room();
|
||||
this.room = room;
|
||||
this.sdk = sdk;
|
||||
this.bindRoomEvents(room, sdk, generation);
|
||||
|
||||
await room.connect(config.url, config.token, { autoSubscribe: false });
|
||||
if (!this.owns(room, generation)) {
|
||||
await safeDisconnect(room);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.state = "prepared";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!this.ownsGeneration(generation)) {
|
||||
return false;
|
||||
}
|
||||
const room = this.invalidate(false);
|
||||
if (room) {
|
||||
await safeDisconnect(room);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
activate(): boolean {
|
||||
if (this.state === "active") {
|
||||
return true;
|
||||
}
|
||||
if (this.state !== "prepared" || !this.room || !this.sdk) {
|
||||
throw new Error(`SFU subscriber is ${this.state}, expected prepared`);
|
||||
}
|
||||
|
||||
const room = this.room;
|
||||
const generation = this.generation;
|
||||
this.state = "active";
|
||||
try {
|
||||
const host = room.remoteParticipants.get(HOST_IDENTITY);
|
||||
if (host) {
|
||||
for (const publication of host.trackPublications.values()) {
|
||||
this.subscribeIfAllowed(publication, host, this.sdk.Track);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (this.owns(room, generation)) {
|
||||
try {
|
||||
this.rollbackSubscriptions(room);
|
||||
this.state = "prepared";
|
||||
this.clearMedia(true);
|
||||
} catch {
|
||||
void this.failClosed(room, generation);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
deactivate(): boolean {
|
||||
if (this.state === "prepared") {
|
||||
return true;
|
||||
}
|
||||
if (this.state !== "active" || !this.room) {
|
||||
if (this.state === "disconnected") {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`SFU subscriber is ${this.state}, expected active`);
|
||||
}
|
||||
|
||||
const room = this.room;
|
||||
try {
|
||||
this.rollbackSubscriptions(room);
|
||||
} catch (error) {
|
||||
void this.failClosed(room, this.generation);
|
||||
throw error;
|
||||
}
|
||||
this.state = "prepared";
|
||||
this.clearMedia(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
const room = this.invalidate(true);
|
||||
if (room) {
|
||||
await safeDisconnect(room);
|
||||
}
|
||||
}
|
||||
|
||||
private bindRoomEvents(
|
||||
room: Room,
|
||||
sdk: LiveKit,
|
||||
generation: number,
|
||||
): void {
|
||||
room.on(
|
||||
sdk.RoomEvent.TrackPublished,
|
||||
(publication: RemoteTrackPublication, participant: RemoteParticipant) => {
|
||||
if (this.state !== "active" || !this.owns(room, generation)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.subscribeIfAllowed(publication, participant, sdk.Track);
|
||||
} catch {
|
||||
void this.failClosed(room, generation);
|
||||
}
|
||||
},
|
||||
);
|
||||
room.on(
|
||||
sdk.RoomEvent.TrackSubscribed,
|
||||
(
|
||||
track: RemoteTrack,
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
) => {
|
||||
if (
|
||||
this.state !== "active" ||
|
||||
!this.owns(room, generation) ||
|
||||
participant.identity !== HOST_IDENTITY ||
|
||||
!this.desiredTrackSids.has(publication.trackSid)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.addTrack(
|
||||
publication.trackSid,
|
||||
publication.source,
|
||||
track.mediaStreamTrack,
|
||||
sdk.Track,
|
||||
);
|
||||
},
|
||||
);
|
||||
room.on(
|
||||
sdk.RoomEvent.TrackUnsubscribed,
|
||||
(
|
||||
_track: RemoteTrack,
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
) => {
|
||||
if (this.owns(room, generation) && participant.identity === HOST_IDENTITY) {
|
||||
this.removeTrack(publication.trackSid);
|
||||
}
|
||||
},
|
||||
);
|
||||
room.on(
|
||||
sdk.RoomEvent.TrackUnpublished,
|
||||
(publication: RemoteTrackPublication, participant: RemoteParticipant) => {
|
||||
if (this.owns(room, generation) && participant.identity === HOST_IDENTITY) {
|
||||
this.desiredTrackSids.delete(publication.trackSid);
|
||||
this.removeTrack(publication.trackSid);
|
||||
}
|
||||
},
|
||||
);
|
||||
room.on(
|
||||
sdk.RoomEvent.ParticipantDisconnected,
|
||||
(participant: RemoteParticipant) => {
|
||||
if (this.owns(room, generation) && participant.identity === HOST_IDENTITY) {
|
||||
this.desiredTrackSids.clear();
|
||||
this.clearMedia(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
room.on(sdk.RoomEvent.Disconnected, () => {
|
||||
if (this.owns(room, generation)) {
|
||||
const notify = this.state !== "connecting";
|
||||
this.invalidate(true);
|
||||
if (notify) {
|
||||
this.notifyTerminalDisconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private subscribeIfAllowed(
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
trackType: typeof Track,
|
||||
): void {
|
||||
if (
|
||||
participant.identity !== HOST_IDENTITY ||
|
||||
!isScreenSource(publication.source, trackType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.desiredTrackSids.add(publication.trackSid);
|
||||
publication.setSubscribed(true);
|
||||
}
|
||||
|
||||
private rollbackSubscriptions(room: Room): void {
|
||||
const host = room.remoteParticipants.get(HOST_IDENTITY);
|
||||
for (const publication of host?.trackPublications.values() ?? []) {
|
||||
if (this.desiredTrackSids.has(publication.trackSid)) {
|
||||
publication.setSubscribed(false);
|
||||
}
|
||||
}
|
||||
this.desiredTrackSids.clear();
|
||||
}
|
||||
|
||||
private addTrack(
|
||||
sid: string,
|
||||
source: Track.Source,
|
||||
track: MediaStreamTrack,
|
||||
trackType: typeof Track,
|
||||
): void {
|
||||
if (source === trackType.Source.ScreenShare) {
|
||||
this.video = { sid, track };
|
||||
} else if (source === trackType.Source.ScreenShareAudio) {
|
||||
this.audio = { sid, track };
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
this.emitStream();
|
||||
}
|
||||
|
||||
private removeTrack(sid: string): void {
|
||||
if (this.video?.sid === sid) {
|
||||
this.video = null;
|
||||
}
|
||||
if (this.audio?.sid === sid) {
|
||||
this.audio = null;
|
||||
}
|
||||
this.emitStream();
|
||||
}
|
||||
|
||||
private emitStream(): void {
|
||||
if (!this.video) {
|
||||
return;
|
||||
}
|
||||
const tracks = [this.video.track];
|
||||
if (this.audio) {
|
||||
tracks.push(this.audio.track);
|
||||
}
|
||||
this.events.onStream(new MediaStream(tracks));
|
||||
}
|
||||
|
||||
private clearMedia(notify: boolean): void {
|
||||
const hadVideo = this.video !== null;
|
||||
this.video = null;
|
||||
this.audio = null;
|
||||
if (notify && hadVideo) {
|
||||
this.events.onStream(null);
|
||||
}
|
||||
}
|
||||
|
||||
private ownsGeneration(generation: number): boolean {
|
||||
return this.generation === generation && this.state !== "disconnected";
|
||||
}
|
||||
|
||||
private owns(room: Room, generation: number): boolean {
|
||||
return this.room === room && this.ownsGeneration(generation);
|
||||
}
|
||||
|
||||
private invalidate(notifyStream: boolean): Room | null {
|
||||
if (this.state === "disconnected") {
|
||||
return null;
|
||||
}
|
||||
++this.generation;
|
||||
this.state = "disconnected";
|
||||
const room = this.room;
|
||||
this.room = null;
|
||||
this.sdk = null;
|
||||
this.desiredTrackSids.clear();
|
||||
this.clearMedia(notifyStream);
|
||||
return room;
|
||||
}
|
||||
|
||||
private async failClosed(room: Room, generation: number): Promise<void> {
|
||||
if (!this.owns(room, generation)) {
|
||||
return;
|
||||
}
|
||||
this.invalidate(true);
|
||||
await safeDisconnect(room);
|
||||
this.notifyTerminalDisconnect();
|
||||
}
|
||||
|
||||
private notifyTerminalDisconnect(): void {
|
||||
if (this.terminalNotified) {
|
||||
return;
|
||||
}
|
||||
this.terminalNotified = true;
|
||||
this.events.onDisconnected?.();
|
||||
}
|
||||
}
|
||||
|
||||
function isScreenSource(source: Track.Source, trackType: typeof Track): boolean {
|
||||
return (
|
||||
source === trackType.Source.ScreenShare ||
|
||||
source === trackType.Source.ScreenShareAudio
|
||||
);
|
||||
}
|
||||
|
||||
async function safeDisconnect(room: Room): Promise<void> {
|
||||
const existing = roomDisconnects.get(room);
|
||||
if (existing) {
|
||||
await existing;
|
||||
return;
|
||||
}
|
||||
const disconnecting = room.disconnect(false).catch(() => undefined);
|
||||
roomDisconnects.set(room, disconnecting);
|
||||
await disconnecting;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from "./stats";
|
||||
|
||||
const MAX_PENDING_CANDIDATES = 64;
|
||||
const MAX_AUTOMATIC_RECOVERY_REQUESTS = 2;
|
||||
const AUTOMATIC_RECOVERY_TIMEOUT_MS = 3_000;
|
||||
type SignalCandidate = Extract<
|
||||
SignalPayload,
|
||||
{ kind: "candidate" }
|
||||
@@ -24,6 +26,10 @@ interface ViewerPeerEvents {
|
||||
) => boolean;
|
||||
onStream: (stream: MediaStream) => void;
|
||||
onUpdate: (snapshot: PeerSnapshot) => void;
|
||||
onRecoveryExhausted?: (
|
||||
parentPeerId: string,
|
||||
connectionId: string,
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
export class ViewerPeer {
|
||||
@@ -39,8 +45,10 @@ export class ViewerPeer {
|
||||
private statsTimer: number | null = null;
|
||||
private statsInFlightConnection: RTCPeerConnection | null = null;
|
||||
private disconnectTimer: number | null = null;
|
||||
private restartRequested = false;
|
||||
private recoveryTimer: number | null = null;
|
||||
private offerRecoveryAttempts = 0;
|
||||
private automaticRecoveryRequests = 0;
|
||||
private recoveryExhaustedReported = false;
|
||||
private disposed = false;
|
||||
private currentIceConfig: IceConfig;
|
||||
private snapshot: PeerSnapshot | null = null;
|
||||
@@ -101,15 +109,16 @@ export class ViewerPeer {
|
||||
if (!connection.localDescription) {
|
||||
throw new Error("Local description was not created");
|
||||
}
|
||||
this.events.sendSignal(parentPeerId, {
|
||||
if (!this.events.sendSignal(parentPeerId, {
|
||||
kind: "description",
|
||||
connectionId,
|
||||
description: {
|
||||
type: "answer",
|
||||
sdp: connection.localDescription.sdp,
|
||||
},
|
||||
});
|
||||
this.restartRequested = false;
|
||||
})) {
|
||||
throw new Error("Signaling is unavailable while sending the answer");
|
||||
}
|
||||
this.offerRecoveryAttempts = 0;
|
||||
} else if (
|
||||
this.connection &&
|
||||
@@ -147,7 +156,10 @@ export class ViewerPeer {
|
||||
)
|
||||
) {
|
||||
this.offerRecoveryAttempts += 1;
|
||||
this.restartRequested = true;
|
||||
this.automaticRecoveryRequests = MAX_AUTOMATIC_RECOVERY_REQUESTS;
|
||||
this.scheduleRecoveryDeadline();
|
||||
} else if (payload.kind === "description") {
|
||||
this.reportRecoveryExhausted();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,7 +189,11 @@ export class ViewerPeer {
|
||||
false,
|
||||
);
|
||||
if (sent) {
|
||||
this.restartRequested = true;
|
||||
this.automaticRecoveryRequests = Math.max(
|
||||
this.automaticRecoveryRequests,
|
||||
1,
|
||||
);
|
||||
this.scheduleRecoveryDeadline();
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
@@ -199,6 +215,7 @@ export class ViewerPeer {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
this.clearRecoveryTimer();
|
||||
this.disposeConnection();
|
||||
this.pendingByConnection.clear();
|
||||
}
|
||||
@@ -209,12 +226,12 @@ export class ViewerPeer {
|
||||
this.disposeConnection();
|
||||
if (parentChanged) {
|
||||
this.offerRecoveryAttempts = 0;
|
||||
this.resetAutomaticRecovery();
|
||||
}
|
||||
this.parentPeerId = parentPeerId;
|
||||
this.connectionId = connectionId;
|
||||
this.remoteStream = new MediaStream();
|
||||
this.statsAccumulator = createStatsAccumulator();
|
||||
this.restartRequested = false;
|
||||
|
||||
const connection = new RTCPeerConnection({
|
||||
iceServers: this.currentIceConfig.iceServers,
|
||||
@@ -280,27 +297,92 @@ export class ViewerPeer {
|
||||
|
||||
private handleConnectionState(state: RTCPeerConnectionState): void {
|
||||
if (state === "connected") {
|
||||
this.restartRequested = false;
|
||||
this.clearDisconnectTimer();
|
||||
this.resetAutomaticRecovery();
|
||||
return;
|
||||
}
|
||||
if (state === "failed") {
|
||||
this.clearDisconnectTimer();
|
||||
if (!this.restartRequested) {
|
||||
this.requestRecovery();
|
||||
}
|
||||
this.attemptAutomaticRecovery();
|
||||
return;
|
||||
}
|
||||
if (state === "disconnected" && this.disconnectTimer === null) {
|
||||
this.disconnectTimer = window.setTimeout(() => {
|
||||
this.disconnectTimer = null;
|
||||
if (this.connection?.connectionState === "disconnected") {
|
||||
this.requestRecovery();
|
||||
this.attemptAutomaticRecovery();
|
||||
}
|
||||
}, 3_000);
|
||||
}
|
||||
}
|
||||
|
||||
private attemptAutomaticRecovery(): void {
|
||||
if (
|
||||
this.disposed ||
|
||||
this.recoveryTimer !== null ||
|
||||
this.recoveryExhaustedReported ||
|
||||
this.connection?.connectionState === "connected" ||
|
||||
!this.connectionId ||
|
||||
!this.parentPeerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this.automaticRecoveryRequests >= MAX_AUTOMATIC_RECOVERY_REQUESTS) {
|
||||
this.reportRecoveryExhausted();
|
||||
return;
|
||||
}
|
||||
const rebuild = this.automaticRecoveryRequests > 0;
|
||||
if (
|
||||
!this.events.sendRestartRequest(
|
||||
this.parentPeerId,
|
||||
this.connectionId,
|
||||
rebuild,
|
||||
)
|
||||
) {
|
||||
this.reportRecoveryExhausted();
|
||||
return;
|
||||
}
|
||||
this.automaticRecoveryRequests += 1;
|
||||
this.scheduleRecoveryDeadline();
|
||||
}
|
||||
|
||||
private scheduleRecoveryDeadline(): void {
|
||||
this.clearRecoveryTimer();
|
||||
this.recoveryTimer = window.setTimeout(() => {
|
||||
this.recoveryTimer = null;
|
||||
if (this.connection?.connectionState !== "connected") {
|
||||
this.attemptAutomaticRecovery();
|
||||
}
|
||||
}, AUTOMATIC_RECOVERY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private reportRecoveryExhausted(): void {
|
||||
if (
|
||||
this.recoveryExhaustedReported ||
|
||||
!this.parentPeerId ||
|
||||
!this.connectionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.clearRecoveryTimer();
|
||||
const reported =
|
||||
this.events.onRecoveryExhausted?.(
|
||||
this.parentPeerId,
|
||||
this.connectionId,
|
||||
) ?? true;
|
||||
if (reported) {
|
||||
this.recoveryExhaustedReported = true;
|
||||
} else {
|
||||
this.scheduleRecoveryDeadline();
|
||||
}
|
||||
}
|
||||
|
||||
private resetAutomaticRecovery(): void {
|
||||
this.clearRecoveryTimer();
|
||||
this.automaticRecoveryRequests = 0;
|
||||
this.recoveryExhaustedReported = false;
|
||||
}
|
||||
|
||||
private queueCandidate(
|
||||
connectionId: string,
|
||||
candidate: SignalCandidate,
|
||||
@@ -416,6 +498,13 @@ export class ViewerPeer {
|
||||
}
|
||||
}
|
||||
|
||||
private clearRecoveryTimer(): void {
|
||||
if (this.recoveryTimer !== null) {
|
||||
window.clearTimeout(this.recoveryTimer);
|
||||
this.recoveryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private disposeConnection(): void {
|
||||
this.clearDisconnectTimer();
|
||||
if (this.statsTimer !== null) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ViteDevServer } from "vite";
|
||||
import type { CreateRoomResponse } from "../shared/protocol.js";
|
||||
import { AccessSession } from "./access-session.js";
|
||||
import { loadConfig, type ServerConfig } from "./config.js";
|
||||
import type { SfuTokenIssuer } from "./livekit-token.js";
|
||||
import { RoomDatabase } from "./room-database.js";
|
||||
import { RoomStore, RoomStoreError } from "./room-store.js";
|
||||
import { SignalingServer } from "./signaling.js";
|
||||
@@ -24,6 +25,7 @@ export interface CreateServerOptions {
|
||||
maxSignalConnections?: number;
|
||||
maxUnauthenticatedSignalConnections?: number;
|
||||
accessSessionTtlSeconds?: number;
|
||||
sfuTokenIssuer?: SfuTokenIssuer;
|
||||
}
|
||||
|
||||
export interface ScreenerServer {
|
||||
@@ -40,6 +42,21 @@ export async function createScreenerServer(
|
||||
): Promise<ScreenerServer> {
|
||||
const config = options.config ?? loadConfig();
|
||||
const now = options.now ?? Date.now;
|
||||
const livekitFallback = config.livekitFallback;
|
||||
const sfuFallback = livekitFallback
|
||||
? {
|
||||
url: livekitFallback.url,
|
||||
tokenIssuer:
|
||||
options.sfuTokenIssuer ??
|
||||
new (await import("./livekit-token.js")).LiveKitTokenIssuer({
|
||||
apiKey: livekitFallback.apiKey,
|
||||
apiSecret: livekitFallback.apiSecret,
|
||||
maxViewersPerRoom: config.maxViewersPerRoom,
|
||||
maxSfuRootsPerRoom: livekitFallback.maxSfuRootsPerRoom,
|
||||
}),
|
||||
maxRoots: livekitFallback.maxSfuRootsPerRoom,
|
||||
}
|
||||
: undefined;
|
||||
const roomStore =
|
||||
options.roomStore ??
|
||||
new RoomStore({
|
||||
@@ -94,6 +111,7 @@ export async function createScreenerServer(
|
||||
server: httpServer,
|
||||
roomStore,
|
||||
peerAssistedMedia: config.peerAssistedMedia,
|
||||
...(sfuFallback ? { sfuFallback } : {}),
|
||||
ice: iceOptions,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
authorizeUpgrade: (request) =>
|
||||
|
||||
@@ -5,10 +5,20 @@ export type RuntimeEnvironment = "development" | "test" | "production";
|
||||
const MAX_TURN_CREDENTIAL_TTL_SECONDS = 3_600;
|
||||
const MAX_ACCESS_PASSWORD_BYTES = 128;
|
||||
const MIN_TURN_SECRET_BYTES = 32;
|
||||
const MIN_LIVEKIT_API_SECRET_BYTES = 32;
|
||||
const MAX_PEER_ASSISTED_VIEWERS = 8;
|
||||
const DEFAULT_MAX_VIEWERS_PER_ROOM = 8;
|
||||
const DEFAULT_MAX_SFU_ROOTS_PER_ROOM = 2;
|
||||
const MAX_SFU_ROOTS_PER_ROOM = 2;
|
||||
const VISIBLE_ASCII_PATTERN = /^[\x21-\x7e]+$/;
|
||||
|
||||
export interface LiveKitFallbackConfig {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
maxSfuRootsPerRoom: number;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
nodeEnv: RuntimeEnvironment;
|
||||
port: number;
|
||||
@@ -21,6 +31,7 @@ export interface ServerConfig {
|
||||
maxRooms: number;
|
||||
maxViewersPerRoom: number;
|
||||
peerAssistedMedia: boolean;
|
||||
livekitFallback?: LiveKitFallbackConfig;
|
||||
stunUrls: readonly string[];
|
||||
turnUrls: readonly string[];
|
||||
turnSharedSecret?: string;
|
||||
@@ -88,6 +99,65 @@ function parseEnvironment(value: string | undefined): RuntimeEnvironment {
|
||||
return environment;
|
||||
}
|
||||
|
||||
function parseLiveKitFallback(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
nodeEnv: RuntimeEnvironment,
|
||||
): LiveKitFallbackConfig | undefined {
|
||||
const url = environment.LIVEKIT_URL?.trim() || undefined;
|
||||
const apiKey = environment.LIVEKIT_API_KEY?.trim() || undefined;
|
||||
const apiSecret = environment.LIVEKIT_API_SECRET?.trim() || undefined;
|
||||
const configuredValues = [url, apiKey, apiSecret].filter(Boolean).length;
|
||||
|
||||
if (configuredValues === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (configuredValues !== 3) {
|
||||
throw new Error(
|
||||
"LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must be configured together",
|
||||
);
|
||||
}
|
||||
if (Buffer.byteLength(apiSecret!) < MIN_LIVEKIT_API_SECRET_BYTES) {
|
||||
throw new Error("LIVEKIT_API_SECRET must contain at least 32 bytes");
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(url!);
|
||||
} catch {
|
||||
throw new Error("LIVEKIT_URL must be a valid ws or wss origin");
|
||||
}
|
||||
if (parsedUrl.protocol !== "ws:" && parsedUrl.protocol !== "wss:") {
|
||||
throw new Error("LIVEKIT_URL must use ws or wss");
|
||||
}
|
||||
if (
|
||||
parsedUrl.username ||
|
||||
parsedUrl.password ||
|
||||
parsedUrl.pathname !== "/" ||
|
||||
parsedUrl.search ||
|
||||
parsedUrl.hash
|
||||
) {
|
||||
throw new Error(
|
||||
"LIVEKIT_URL must be an origin without credentials, path, query, or fragment",
|
||||
);
|
||||
}
|
||||
if (nodeEnv === "production" && parsedUrl.protocol !== "wss:") {
|
||||
throw new Error("LIVEKIT_URL must use wss in production");
|
||||
}
|
||||
|
||||
return {
|
||||
url: parsedUrl.origin,
|
||||
apiKey: apiKey!,
|
||||
apiSecret: apiSecret!,
|
||||
maxSfuRootsPerRoom: parseBoundedInteger(
|
||||
environment.MAX_SFU_ROOTS_PER_ROOM,
|
||||
DEFAULT_MAX_SFU_ROOTS_PER_ROOM,
|
||||
"MAX_SFU_ROOTS_PER_ROOM",
|
||||
1,
|
||||
MAX_SFU_ROOTS_PER_ROOM,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseUrlList(value: string | undefined, name: string): string[] {
|
||||
if (!value?.trim()) {
|
||||
return [];
|
||||
@@ -287,12 +357,27 @@ export function loadConfig(
|
||||
false,
|
||||
"PEER_ASSISTED_MEDIA",
|
||||
);
|
||||
const livekitFallback = parseLiveKitFallback(environment, nodeEnv);
|
||||
|
||||
if (livekitFallback && !peerAssistedMedia) {
|
||||
throw new Error("LiveKit fallback requires PEER_ASSISTED_MEDIA=true");
|
||||
}
|
||||
if ((turnUrls.length > 0) !== Boolean(turnSharedSecret)) {
|
||||
throw new Error(
|
||||
"TURN_URLS and TURN_SHARED_SECRET must either both be configured or both be absent",
|
||||
);
|
||||
}
|
||||
const configuredSecrets = [
|
||||
accessPassword,
|
||||
turnSharedSecret,
|
||||
livekitFallback?.apiKey,
|
||||
livekitFallback?.apiSecret,
|
||||
].filter((secret): secret is string => secret !== undefined);
|
||||
if (new Set(configuredSecrets).size !== configuredSecrets.length) {
|
||||
throw new Error(
|
||||
"ACCESS_PASSWORD, TURN_SHARED_SECRET, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values",
|
||||
);
|
||||
}
|
||||
if (
|
||||
accessPassword &&
|
||||
(!VISIBLE_ASCII_PATTERN.test(accessPassword) ||
|
||||
@@ -347,6 +432,7 @@ export function loadConfig(
|
||||
maxRooms: parsePositiveInteger(environment.MAX_ROOMS, 1_000, "MAX_ROOMS"),
|
||||
maxViewersPerRoom,
|
||||
peerAssistedMedia,
|
||||
livekitFallback,
|
||||
stunUrls,
|
||||
turnUrls,
|
||||
turnSharedSecret,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
AccessToken,
|
||||
RoomConfiguration,
|
||||
TrackSource,
|
||||
} from "livekit-server-sdk";
|
||||
|
||||
import {
|
||||
MAX_VIEWERS_PER_ROOM_LIMIT,
|
||||
type Role,
|
||||
} from "../shared/protocol.js";
|
||||
|
||||
const LIVEKIT_TOKEN_TTL_SECONDS = 5 * 60;
|
||||
const MAX_SFU_ROOTS_PER_ROOM_LIMIT = 2;
|
||||
const ROOM_ID_PATTERN = /^[1-9]\d{0,11}$/;
|
||||
const OPAQUE_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
|
||||
|
||||
export interface SfuTokenRequest {
|
||||
roomId: string;
|
||||
role: Role;
|
||||
peerId: string;
|
||||
publicationGeneration: string;
|
||||
allowlistedRootPeerIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface SfuTokenIssuer {
|
||||
issueToken(request: SfuTokenRequest): Promise<string>;
|
||||
}
|
||||
|
||||
export interface LiveKitTokenIssuerOptions {
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
maxViewersPerRoom: number;
|
||||
maxSfuRootsPerRoom: number;
|
||||
}
|
||||
|
||||
export class LiveKitTokenIssuer implements SfuTokenIssuer {
|
||||
constructor(private readonly options: LiveKitTokenIssuerOptions) {
|
||||
if (!options.apiKey || Buffer.byteLength(options.apiSecret) < 32) {
|
||||
throw new Error("LiveKit API credentials are invalid");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(options.maxViewersPerRoom) ||
|
||||
options.maxViewersPerRoom < 1 ||
|
||||
options.maxViewersPerRoom > MAX_VIEWERS_PER_ROOM_LIMIT
|
||||
) {
|
||||
throw new Error("LiveKit viewer limit is invalid");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(options.maxSfuRootsPerRoom) ||
|
||||
options.maxSfuRootsPerRoom < 1 ||
|
||||
options.maxSfuRootsPerRoom > MAX_SFU_ROOTS_PER_ROOM_LIMIT
|
||||
) {
|
||||
throw new Error("LiveKit SFU root limit is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
async issueToken(request: SfuTokenRequest): Promise<string> {
|
||||
validateTokenRequest(request, this.options.maxSfuRootsPerRoom);
|
||||
|
||||
const isHost = request.role === "host";
|
||||
const room = liveKitRoomName(
|
||||
request.roomId,
|
||||
request.publicationGeneration,
|
||||
);
|
||||
const token = new AccessToken(
|
||||
this.options.apiKey,
|
||||
this.options.apiSecret,
|
||||
{
|
||||
identity: isHost ? "host" : `viewer:${request.peerId}`,
|
||||
ttl: LIVEKIT_TOKEN_TTL_SECONDS,
|
||||
},
|
||||
);
|
||||
token.addGrant({
|
||||
roomJoin: true,
|
||||
room,
|
||||
canPublish: isHost,
|
||||
canPublishSources: isHost
|
||||
? [TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO]
|
||||
: undefined,
|
||||
canSubscribe: !isHost,
|
||||
canPublishData: false,
|
||||
canUpdateOwnMetadata: false,
|
||||
});
|
||||
token.roomConfig = new RoomConfiguration({
|
||||
name: room,
|
||||
maxParticipants: this.options.maxViewersPerRoom + 1,
|
||||
});
|
||||
|
||||
return await token.toJwt();
|
||||
}
|
||||
}
|
||||
|
||||
function validateTokenRequest(
|
||||
request: SfuTokenRequest,
|
||||
maxSfuRootsPerRoom: number,
|
||||
): void {
|
||||
if (!ROOM_ID_PATTERN.test(request.roomId)) {
|
||||
throw new Error("LiveKit room ID is invalid");
|
||||
}
|
||||
if (
|
||||
!OPAQUE_ID_PATTERN.test(request.peerId) ||
|
||||
!OPAQUE_ID_PATTERN.test(request.publicationGeneration)
|
||||
) {
|
||||
throw new Error("LiveKit participant identity is invalid");
|
||||
}
|
||||
if (
|
||||
request.allowlistedRootPeerIds.length > maxSfuRootsPerRoom ||
|
||||
request.allowlistedRootPeerIds.some(
|
||||
(peerId) => !OPAQUE_ID_PATTERN.test(peerId),
|
||||
)
|
||||
) {
|
||||
throw new Error("LiveKit SFU root allowlist is invalid");
|
||||
}
|
||||
|
||||
const roots = new Set(request.allowlistedRootPeerIds);
|
||||
if (roots.size !== request.allowlistedRootPeerIds.length) {
|
||||
throw new Error("LiveKit SFU root allowlist contains duplicates");
|
||||
}
|
||||
if (request.role === "viewer" && !roots.has(request.peerId)) {
|
||||
throw new Error("LiveKit viewer is not an allowlisted SFU root");
|
||||
}
|
||||
}
|
||||
|
||||
function liveKitRoomName(
|
||||
roomId: string,
|
||||
publicationGeneration: string,
|
||||
): string {
|
||||
return `screener-${roomId}-${publicationGeneration}`;
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
MAX_MEDIA_ROUTE_REVISION,
|
||||
MAX_VIEWERS_PER_ROOM_LIMIT,
|
||||
participantRouteAssignmentSchema,
|
||||
sfuPublicationGenerationSchema,
|
||||
type MediaRoutePhase,
|
||||
type ParticipantRouteAssignment,
|
||||
} from "../shared/protocol.js";
|
||||
|
||||
const MAX_PARTICIPANTS_PER_ROOM = MAX_VIEWERS_PER_ROOM_LIMIT + 1;
|
||||
|
||||
export interface RoomSfuRoute {
|
||||
publicationGeneration: string | null;
|
||||
rootPeerIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface RoomMediaRoute {
|
||||
revision: number;
|
||||
assignments: ReadonlyMap<string, ParticipantRouteAssignment>;
|
||||
sfu: RoomSfuRoute;
|
||||
}
|
||||
|
||||
export interface PendingRoomMediaRoute {
|
||||
route: RoomMediaRoute;
|
||||
expectedParticipantIds: ReadonlySet<string>;
|
||||
readyParticipantIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface MediaRouteControllerOptions {
|
||||
hostPeerId: string;
|
||||
assignments: ReadonlyMap<string, ParticipantRouteAssignment>;
|
||||
revision?: number;
|
||||
sfuPublicationGeneration?: string | null;
|
||||
sfuRootPeerIds?: readonly string[];
|
||||
}
|
||||
|
||||
export interface PrepareMediaRouteInput {
|
||||
assignments: ReadonlyMap<string, ParticipantRouteAssignment>;
|
||||
expectedParticipantIds: ReadonlySet<string>;
|
||||
sfuPublicationGeneration?: string | null;
|
||||
sfuRootPeerIds?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ReconcileBaselineInput {
|
||||
assignments: ReadonlyMap<string, ParticipantRouteAssignment>;
|
||||
sfuPublicationGeneration?: string | null;
|
||||
sfuRootPeerIds?: readonly string[];
|
||||
}
|
||||
|
||||
interface StoredPendingRoute {
|
||||
route: RoomMediaRoute;
|
||||
expectedParticipantIds: Set<string>;
|
||||
readyParticipantIds: Set<string>;
|
||||
}
|
||||
|
||||
export class MediaRouteController {
|
||||
private activeRoute: RoomMediaRoute;
|
||||
private pendingRoute: StoredPendingRoute | undefined;
|
||||
private latestRevision: number;
|
||||
private readonly hostPeerId: string;
|
||||
|
||||
constructor(options: MediaRouteControllerOptions) {
|
||||
this.hostPeerId = options.hostPeerId;
|
||||
const revision = options.revision ?? 0;
|
||||
assertRevision(revision);
|
||||
this.activeRoute = createRoute(
|
||||
revision,
|
||||
options.assignments,
|
||||
options.sfuPublicationGeneration ?? null,
|
||||
options.sfuRootPeerIds ?? [],
|
||||
this.hostPeerId,
|
||||
);
|
||||
this.latestRevision = revision;
|
||||
}
|
||||
|
||||
getActiveRoute(): RoomMediaRoute {
|
||||
return cloneRoute(this.activeRoute);
|
||||
}
|
||||
|
||||
getPendingRoute(): PendingRoomMediaRoute | undefined {
|
||||
const pending = this.pendingRoute;
|
||||
return pending
|
||||
? {
|
||||
route: cloneRoute(pending.route),
|
||||
expectedParticipantIds: new Set(pending.expectedParticipantIds),
|
||||
readyParticipantIds: new Set(pending.readyParticipantIds),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
reconcileBaseline(input: ReconcileBaselineInput): number | undefined {
|
||||
if (this.pendingRoute) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const revision = this.nextRevision();
|
||||
const route = createRoute(
|
||||
revision,
|
||||
input.assignments,
|
||||
input.sfuPublicationGeneration === undefined
|
||||
? this.activeRoute.sfu.publicationGeneration
|
||||
: input.sfuPublicationGeneration,
|
||||
input.sfuRootPeerIds ?? this.activeRoute.sfu.rootPeerIds,
|
||||
this.hostPeerId,
|
||||
);
|
||||
if (hasSameTopology(route, this.activeRoute)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.activeRoute = route;
|
||||
this.latestRevision = revision;
|
||||
return revision;
|
||||
}
|
||||
|
||||
prepare(input: PrepareMediaRouteInput): number | undefined {
|
||||
if (this.pendingRoute) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const revision = this.nextRevision();
|
||||
const route = createRoute(
|
||||
revision,
|
||||
input.assignments,
|
||||
input.sfuPublicationGeneration ?? null,
|
||||
input.sfuRootPeerIds ?? [],
|
||||
this.hostPeerId,
|
||||
);
|
||||
const expectedParticipantIds = new Set(input.expectedParticipantIds);
|
||||
if (expectedParticipantIds.size > MAX_PARTICIPANTS_PER_ROOM) {
|
||||
throw new Error("Too many expected media route participants");
|
||||
}
|
||||
for (const peerId of expectedParticipantIds) {
|
||||
if (!route.assignments.has(peerId)) {
|
||||
throw new Error(`Expected participant ${peerId} has no route assignment`);
|
||||
}
|
||||
}
|
||||
|
||||
this.latestRevision = revision;
|
||||
this.pendingRoute = {
|
||||
route,
|
||||
expectedParticipantIds,
|
||||
readyParticipantIds: new Set(),
|
||||
};
|
||||
return revision;
|
||||
}
|
||||
|
||||
ready(peerId: string, revision: number, phase: MediaRoutePhase): boolean {
|
||||
const pending = this.pendingRoute;
|
||||
if (
|
||||
phase !== "prepare" ||
|
||||
!pending ||
|
||||
pending.route.revision !== revision ||
|
||||
!pending.expectedParticipantIds.has(peerId) ||
|
||||
pending.readyParticipantIds.has(peerId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
pending.readyParticipantIds.add(peerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
commit(revision: number): boolean {
|
||||
const pending = this.pendingRoute;
|
||||
if (!pending || pending.route.revision !== revision) {
|
||||
return false;
|
||||
}
|
||||
for (const peerId of pending.expectedParticipantIds) {
|
||||
if (!pending.readyParticipantIds.has(peerId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.activeRoute = pending.route;
|
||||
this.pendingRoute = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
abort(revision: number): boolean {
|
||||
const pending = this.pendingRoute;
|
||||
if (!pending || pending.route.revision !== revision) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rollbackRevision = this.nextRevision();
|
||||
this.activeRoute = cloneRoute(this.activeRoute, rollbackRevision);
|
||||
this.latestRevision = rollbackRevision;
|
||||
this.pendingRoute = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
hostActiveMediaEdges(): number {
|
||||
const edgeCount = hostMediaEdges(this.activeRoute, this.hostPeerId);
|
||||
if (edgeCount > 2) {
|
||||
throw new Error("Host active media edge budget exceeded");
|
||||
}
|
||||
return edgeCount;
|
||||
}
|
||||
|
||||
private nextRevision(): number {
|
||||
if (this.latestRevision >= MAX_MEDIA_ROUTE_REVISION) {
|
||||
throw new Error("Media route revision space exhausted");
|
||||
}
|
||||
return this.latestRevision + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function createRoute(
|
||||
revision: number,
|
||||
assignments: ReadonlyMap<string, ParticipantRouteAssignment>,
|
||||
publicationGeneration: string | null,
|
||||
rootPeerIds: readonly string[],
|
||||
hostPeerId: string,
|
||||
): RoomMediaRoute {
|
||||
assertRevision(revision);
|
||||
if (
|
||||
assignments.size === 0 ||
|
||||
assignments.size > MAX_PARTICIPANTS_PER_ROOM
|
||||
) {
|
||||
throw new Error("Media route participant count is outside room bounds");
|
||||
}
|
||||
if (publicationGeneration !== null) {
|
||||
sfuPublicationGenerationSchema.parse(publicationGeneration);
|
||||
}
|
||||
|
||||
const clonedAssignments = new Map<string, ParticipantRouteAssignment>();
|
||||
for (const [peerId, assignment] of assignments) {
|
||||
const parsed = participantRouteAssignmentSchema.parse(assignment);
|
||||
clonedAssignments.set(peerId, cloneAssignment(parsed));
|
||||
}
|
||||
const route: RoomMediaRoute = {
|
||||
revision,
|
||||
assignments: clonedAssignments,
|
||||
sfu: {
|
||||
publicationGeneration,
|
||||
rootPeerIds: [...rootPeerIds],
|
||||
},
|
||||
};
|
||||
assertRouteInvariants(route, hostPeerId);
|
||||
return route;
|
||||
}
|
||||
|
||||
function assertRouteInvariants(
|
||||
route: RoomMediaRoute,
|
||||
hostPeerId: string,
|
||||
): void {
|
||||
const hostAssignment = route.assignments.get(hostPeerId);
|
||||
if (!hostAssignment) {
|
||||
throw new Error("Media route is missing its host assignment");
|
||||
}
|
||||
if (hostAssignment.upstream.kind !== "none") {
|
||||
throw new Error("The host cannot have a media upstream");
|
||||
}
|
||||
if (
|
||||
hostAssignment.sfuPublicationGeneration !==
|
||||
route.sfu.publicationGeneration
|
||||
) {
|
||||
throw new Error("Host and room SFU publication generations differ");
|
||||
}
|
||||
|
||||
const rootPeerIds = new Set(route.sfu.rootPeerIds);
|
||||
if (
|
||||
rootPeerIds.size !== route.sfu.rootPeerIds.length ||
|
||||
rootPeerIds.size > MAX_VIEWERS_PER_ROOM_LIMIT
|
||||
) {
|
||||
throw new Error("SFU root participants must be unique and bounded");
|
||||
}
|
||||
if (
|
||||
(route.sfu.publicationGeneration === null) !== (rootPeerIds.size === 0)
|
||||
) {
|
||||
throw new Error("SFU roots and publication generation must be active together");
|
||||
}
|
||||
|
||||
for (const [peerId, assignment] of route.assignments) {
|
||||
if (
|
||||
peerId !== hostPeerId &&
|
||||
assignment.sfuPublicationGeneration !== null
|
||||
) {
|
||||
throw new Error("Only the host may own an SFU publication generation");
|
||||
}
|
||||
if (assignment.childPeerIds.includes(peerId)) {
|
||||
throw new Error("A participant cannot be its own media child");
|
||||
}
|
||||
|
||||
for (const childPeerId of assignment.childPeerIds) {
|
||||
const childAssignment = route.assignments.get(childPeerId);
|
||||
if (!childAssignment) {
|
||||
throw new Error(`Media child ${childPeerId} has no assignment`);
|
||||
}
|
||||
if (
|
||||
childAssignment.upstream.kind !== "peer" ||
|
||||
childAssignment.upstream.peerId !== peerId
|
||||
) {
|
||||
throw new Error("Peer media edges must be reciprocal");
|
||||
}
|
||||
}
|
||||
|
||||
if (assignment.upstream.kind === "peer") {
|
||||
const parentAssignment = route.assignments.get(
|
||||
assignment.upstream.peerId,
|
||||
);
|
||||
if (!parentAssignment?.childPeerIds.includes(peerId)) {
|
||||
throw new Error("Peer media edges must be reciprocal");
|
||||
}
|
||||
}
|
||||
if (assignment.upstream.kind === "sfu" && !rootPeerIds.has(peerId)) {
|
||||
throw new Error("Every SFU upstream must belong to the root allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
for (const rootPeerId of rootPeerIds) {
|
||||
const rootAssignment = route.assignments.get(rootPeerId);
|
||||
if (!rootAssignment || rootAssignment.upstream.kind !== "sfu") {
|
||||
throw new Error("Every SFU root must have the SFU as its upstream");
|
||||
}
|
||||
}
|
||||
|
||||
assertAcyclicPeerEdges(route.assignments);
|
||||
if (hostMediaEdges(route, hostPeerId) > 2) {
|
||||
throw new Error("Host active media edge budget exceeded");
|
||||
}
|
||||
}
|
||||
|
||||
function assertAcyclicPeerEdges(
|
||||
assignments: ReadonlyMap<string, ParticipantRouteAssignment>,
|
||||
): void {
|
||||
for (const startPeerId of assignments.keys()) {
|
||||
const ancestors = new Set([startPeerId]);
|
||||
let assignment = assignments.get(startPeerId);
|
||||
while (assignment?.upstream.kind === "peer") {
|
||||
const parentPeerId = assignment.upstream.peerId;
|
||||
if (ancestors.has(parentPeerId)) {
|
||||
throw new Error("Peer media route contains a cycle");
|
||||
}
|
||||
ancestors.add(parentPeerId);
|
||||
assignment = assignments.get(parentPeerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hostMediaEdges(route: RoomMediaRoute, hostPeerId: string): number {
|
||||
const directChildren = route.assignments.get(hostPeerId)?.childPeerIds.length;
|
||||
if (directChildren === undefined) {
|
||||
throw new Error("Media route is missing its host assignment");
|
||||
}
|
||||
return directChildren + (route.sfu.publicationGeneration === null ? 0 : 1);
|
||||
}
|
||||
|
||||
function hasSameTopology(left: RoomMediaRoute, right: RoomMediaRoute): boolean {
|
||||
if (
|
||||
left.sfu.publicationGeneration !== right.sfu.publicationGeneration ||
|
||||
left.sfu.rootPeerIds.length !== right.sfu.rootPeerIds.length ||
|
||||
left.sfu.rootPeerIds.some(
|
||||
(peerId, index) => peerId !== right.sfu.rootPeerIds[index],
|
||||
) ||
|
||||
left.assignments.size !== right.assignments.size
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [peerId, leftAssignment] of left.assignments) {
|
||||
const rightAssignment = right.assignments.get(peerId);
|
||||
if (
|
||||
!rightAssignment ||
|
||||
leftAssignment.sfuPublicationGeneration !==
|
||||
rightAssignment.sfuPublicationGeneration ||
|
||||
leftAssignment.upstream.kind !== rightAssignment.upstream.kind ||
|
||||
(leftAssignment.upstream.kind === "peer" &&
|
||||
(rightAssignment.upstream.kind !== "peer" ||
|
||||
leftAssignment.upstream.peerId !==
|
||||
rightAssignment.upstream.peerId)) ||
|
||||
leftAssignment.childPeerIds.length !==
|
||||
rightAssignment.childPeerIds.length ||
|
||||
leftAssignment.childPeerIds.some(
|
||||
(childPeerId, index) =>
|
||||
childPeerId !== rightAssignment.childPeerIds[index],
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function cloneRoute(
|
||||
route: RoomMediaRoute,
|
||||
revision = route.revision,
|
||||
): RoomMediaRoute {
|
||||
return {
|
||||
revision,
|
||||
assignments: new Map(
|
||||
[...route.assignments].map(([peerId, assignment]) => [
|
||||
peerId,
|
||||
cloneAssignment(assignment),
|
||||
]),
|
||||
),
|
||||
sfu: {
|
||||
publicationGeneration: route.sfu.publicationGeneration,
|
||||
rootPeerIds: [...route.sfu.rootPeerIds],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cloneAssignment(
|
||||
assignment: ParticipantRouteAssignment,
|
||||
): ParticipantRouteAssignment {
|
||||
return {
|
||||
upstream:
|
||||
assignment.upstream.kind === "peer"
|
||||
? { kind: "peer", peerId: assignment.upstream.peerId }
|
||||
: { kind: assignment.upstream.kind },
|
||||
childPeerIds: [...assignment.childPeerIds],
|
||||
sfuPublicationGeneration: assignment.sfuPublicationGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
function assertRevision(revision: number): void {
|
||||
if (
|
||||
!Number.isSafeInteger(revision) ||
|
||||
revision < 0 ||
|
||||
revision > MAX_MEDIA_ROUTE_REVISION
|
||||
) {
|
||||
throw new Error("Media route revision is outside protocol bounds");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { MediaAssignment } from "../shared/protocol.js";
|
||||
import type {
|
||||
MediaAssignment,
|
||||
RelayDownstreamEdges,
|
||||
} from "../shared/protocol.js";
|
||||
|
||||
export interface MediaAssignmentChange {
|
||||
peerId: string;
|
||||
@@ -6,10 +9,13 @@ export interface MediaAssignmentChange {
|
||||
mediaAssignment: MediaAssignment;
|
||||
}
|
||||
|
||||
export const MAX_PEER_RELAY_DEPTH = 4;
|
||||
|
||||
interface RelayViewer {
|
||||
order: number;
|
||||
parentPeerId: string | null;
|
||||
childPeerIds: string[];
|
||||
downstreamEdges: RelayDownstreamEdges;
|
||||
}
|
||||
|
||||
interface RelayRoom {
|
||||
@@ -56,13 +62,17 @@ export class PeerRelayTopology {
|
||||
): MediaAssignmentChange[] {
|
||||
const room = this.room(roomId);
|
||||
const before = snapshot(room);
|
||||
if (!room.viewers.has(peerId)) {
|
||||
const existingViewer = room.viewers.get(peerId);
|
||||
if (!existingViewer) {
|
||||
room.viewers.set(peerId, {
|
||||
order: room.nextOrder,
|
||||
parentPeerId: null,
|
||||
childPeerIds: [],
|
||||
downstreamEdges: 0,
|
||||
});
|
||||
room.nextOrder += 1;
|
||||
} else {
|
||||
existingViewer.downstreamEdges = 0;
|
||||
}
|
||||
for (const [candidatePeerId, viewer] of orderedViewers(room)) {
|
||||
if (viewer.parentPeerId === null) {
|
||||
@@ -72,6 +82,28 @@ export class PeerRelayTopology {
|
||||
return changedAssignments(before, snapshot(room));
|
||||
}
|
||||
|
||||
setViewerRelayCapacity(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
downstreamEdges: RelayDownstreamEdges,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
): MediaAssignmentChange[] {
|
||||
const room = this.rooms.get(roomId);
|
||||
const viewer = room?.viewers.get(peerId);
|
||||
if (!room || !viewer) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const before = snapshot(room);
|
||||
viewer.downstreamEdges = downstreamEdges;
|
||||
for (const [candidatePeerId, candidate] of orderedViewers(room)) {
|
||||
if (candidate.parentPeerId === null) {
|
||||
this.assignViewer(room, candidatePeerId, connectedPeerIds);
|
||||
}
|
||||
}
|
||||
return changedAssignments(before, snapshot(room));
|
||||
}
|
||||
|
||||
removeViewer(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
@@ -128,6 +160,62 @@ export class PeerRelayTopology {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
getAssignments(roomId: string): Map<string, MediaAssignment> {
|
||||
const room = this.rooms.get(roomId);
|
||||
return room ? snapshot(room) : new Map();
|
||||
}
|
||||
|
||||
getHostPeerId(roomId: string): string | undefined {
|
||||
return this.rooms.get(roomId)?.hostPeerId;
|
||||
}
|
||||
|
||||
reassignViewer(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
excludedParentPeerIds: ReadonlySet<string>,
|
||||
maxDepth = MAX_PEER_RELAY_DEPTH,
|
||||
): MediaAssignmentChange[] | undefined {
|
||||
const room = this.rooms.get(roomId);
|
||||
const viewer = room?.viewers.get(peerId);
|
||||
if (
|
||||
!room?.hostPeerId ||
|
||||
!viewer ||
|
||||
!connectedPeerIds.has(peerId) ||
|
||||
!Number.isSafeInteger(maxDepth) ||
|
||||
maxDepth < 1
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const subtreePeerIds = collectSubtreePeerIds(room, peerId);
|
||||
const subtreeHeight = maximumSubtreeDepth(room, peerId);
|
||||
if (maximumDepthOutsideSubtree(room, subtreePeerIds) > maxDepth) {
|
||||
return undefined;
|
||||
}
|
||||
const parentPeerId = findReassignmentParent(
|
||||
room,
|
||||
connectedPeerIds,
|
||||
excludedParentPeerIds,
|
||||
subtreePeerIds,
|
||||
maxDepth - subtreeHeight - 1,
|
||||
);
|
||||
if (!parentPeerId || parentPeerId === viewer.parentPeerId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const before = snapshot(room);
|
||||
this.detach(room, peerId, viewer.parentPeerId);
|
||||
viewer.parentPeerId = parentPeerId;
|
||||
const children = childPeerIds(room, parentPeerId);
|
||||
children.push(peerId);
|
||||
children.sort(
|
||||
(left, right) =>
|
||||
room.viewers.get(left)!.order - room.viewers.get(right)!.order,
|
||||
);
|
||||
return changedAssignments(before, snapshot(room));
|
||||
}
|
||||
|
||||
isParentOf(roomId: string, parentPeerId: string, childPeerId: string): boolean {
|
||||
return (
|
||||
this.rooms.get(roomId)?.viewers.get(childPeerId)?.parentPeerId ===
|
||||
@@ -165,7 +253,11 @@ export class PeerRelayTopology {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const parentPeerId = findShallowestAvailableParent(room, connectedPeerIds);
|
||||
const parentPeerId = findShallowestAvailableParent(
|
||||
room,
|
||||
connectedPeerIds,
|
||||
MAX_PEER_RELAY_DEPTH - maximumSubtreeDepth(room, peerId) - 1,
|
||||
);
|
||||
if (!parentPeerId) {
|
||||
return;
|
||||
}
|
||||
@@ -197,28 +289,128 @@ export class PeerRelayTopology {
|
||||
function findShallowestAvailableParent(
|
||||
room: RelayRoom,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
maximumParentDepth: number,
|
||||
): string | undefined {
|
||||
const hostPeerId = room.hostPeerId;
|
||||
if (!hostPeerId || !connectedPeerIds.has(hostPeerId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const queue = [hostPeerId];
|
||||
const queue: Array<{ peerId: string; depth: number }> = [
|
||||
{ peerId: hostPeerId, depth: 0 },
|
||||
];
|
||||
while (queue.length > 0) {
|
||||
const peerId = queue.shift()!;
|
||||
if (!connectedPeerIds.has(peerId)) {
|
||||
const { peerId, depth } = queue.shift()!;
|
||||
if (depth > maximumParentDepth || !connectedPeerIds.has(peerId)) {
|
||||
continue;
|
||||
}
|
||||
const children = childPeerIds(room, peerId);
|
||||
const capacity = peerId === hostPeerId ? 2 : 1;
|
||||
const capacity = downstreamCapacity(room, peerId);
|
||||
if (children.length < capacity) {
|
||||
return peerId;
|
||||
}
|
||||
queue.push(...children);
|
||||
for (const childPeerId of children) {
|
||||
queue.push({ peerId: childPeerId, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findReassignmentParent(
|
||||
room: RelayRoom,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
excludedParentPeerIds: ReadonlySet<string>,
|
||||
subtreePeerIds: ReadonlySet<string>,
|
||||
maximumParentDepth: number,
|
||||
): string | undefined {
|
||||
const hostPeerId = room.hostPeerId;
|
||||
if (!hostPeerId || !connectedPeerIds.has(hostPeerId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const queue: Array<{ peerId: string; depth: number }> = [
|
||||
{ peerId: hostPeerId, depth: 0 },
|
||||
];
|
||||
while (queue.length > 0) {
|
||||
const { peerId, depth } = queue.shift()!;
|
||||
if (depth > maximumParentDepth || !connectedPeerIds.has(peerId)) {
|
||||
continue;
|
||||
}
|
||||
const children = childPeerIds(room, peerId);
|
||||
const capacity = downstreamCapacity(room, peerId);
|
||||
if (
|
||||
children.length < capacity &&
|
||||
!excludedParentPeerIds.has(peerId) &&
|
||||
!subtreePeerIds.has(peerId)
|
||||
) {
|
||||
return peerId;
|
||||
}
|
||||
for (const childPeerId of children) {
|
||||
queue.push({ peerId: childPeerId, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function downstreamCapacity(room: RelayRoom, peerId: string): number {
|
||||
return room.hostPeerId === peerId
|
||||
? 2
|
||||
: (room.viewers.get(peerId)?.downstreamEdges ?? 0);
|
||||
}
|
||||
|
||||
function collectSubtreePeerIds(room: RelayRoom, rootPeerId: string): Set<string> {
|
||||
const peerIds = new Set<string>();
|
||||
const queue = [rootPeerId];
|
||||
while (queue.length > 0) {
|
||||
const peerId = queue.shift()!;
|
||||
if (peerIds.has(peerId)) {
|
||||
continue;
|
||||
}
|
||||
peerIds.add(peerId);
|
||||
queue.push(...(room.viewers.get(peerId)?.childPeerIds ?? []));
|
||||
}
|
||||
return peerIds;
|
||||
}
|
||||
|
||||
function maximumSubtreeDepth(room: RelayRoom, rootPeerId: string): number {
|
||||
let maximumDepth = 0;
|
||||
const queue: Array<{ peerId: string; depth: number }> = [
|
||||
{ peerId: rootPeerId, depth: 0 },
|
||||
];
|
||||
while (queue.length > 0) {
|
||||
const { peerId, depth } = queue.shift()!;
|
||||
maximumDepth = Math.max(maximumDepth, depth);
|
||||
for (const childPeerId of room.viewers.get(peerId)?.childPeerIds ?? []) {
|
||||
queue.push({ peerId: childPeerId, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
return maximumDepth;
|
||||
}
|
||||
|
||||
function maximumDepthOutsideSubtree(
|
||||
room: RelayRoom,
|
||||
excludedPeerIds: ReadonlySet<string>,
|
||||
): number {
|
||||
const hostPeerId = room.hostPeerId;
|
||||
if (!hostPeerId) {
|
||||
return 0;
|
||||
}
|
||||
let maximumDepth = 0;
|
||||
const queue: Array<{ peerId: string; depth: number }> = [
|
||||
{ peerId: hostPeerId, depth: 0 },
|
||||
];
|
||||
while (queue.length > 0) {
|
||||
const { peerId, depth } = queue.shift()!;
|
||||
maximumDepth = Math.max(maximumDepth, depth);
|
||||
for (const childPeerId of childPeerIds(room, peerId)) {
|
||||
if (!excludedPeerIds.has(childPeerId)) {
|
||||
queue.push({ peerId: childPeerId, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return maximumDepth;
|
||||
}
|
||||
|
||||
function childPeerIds(room: RelayRoom, peerId: string): string[] {
|
||||
if (room.hostPeerId === peerId) {
|
||||
return room.hostChildPeerIds;
|
||||
|
||||
+143
-133
@@ -15,9 +15,9 @@ import {
|
||||
} from "../shared/protocol.js";
|
||||
import { RoomStore, RoomStoreError } from "./room-store.js";
|
||||
import {
|
||||
PeerRelayTopology,
|
||||
type MediaAssignmentChange,
|
||||
} from "./peer-relay-topology.js";
|
||||
HybridMediaRouter,
|
||||
type SfuFallbackOptions,
|
||||
} from "./hybrid-media-router.js";
|
||||
import { createIceConfig, type IceConfigOptions } from "./turn.js";
|
||||
|
||||
type ErrorCode = Extract<ServerMessage, { type: "error" }>["code"];
|
||||
@@ -31,6 +31,7 @@ interface AuthenticatedSession {
|
||||
role: Role;
|
||||
peerId: string;
|
||||
roomExpiresAtMs: number | null;
|
||||
shareGeneration: string | null;
|
||||
}
|
||||
|
||||
interface SocketState {
|
||||
@@ -44,6 +45,7 @@ export interface SignalingOptions {
|
||||
server: HttpServer;
|
||||
roomStore: RoomStore;
|
||||
peerAssistedMedia: boolean;
|
||||
sfuFallback?: SfuFallbackOptions;
|
||||
ice: IceConfigOptions;
|
||||
allowedOrigins: ReadonlySet<string>;
|
||||
authorizeUpgrade: (request: IncomingMessage) => boolean;
|
||||
@@ -71,7 +73,8 @@ export class SignalingServer {
|
||||
string,
|
||||
QualityProfileId
|
||||
>();
|
||||
private readonly peerRelayTopology = new PeerRelayTopology();
|
||||
private readonly shareGenerationsByRoom = new Map<string, string>();
|
||||
private readonly hybridMediaRouter?: HybridMediaRouter;
|
||||
private readonly now: () => number;
|
||||
private readonly authenticationTimeoutMs: number;
|
||||
private readonly viewerDisconnectGraceMs: number;
|
||||
@@ -100,6 +103,25 @@ export class SignalingServer {
|
||||
) {
|
||||
throw new Error("WebSocket connection limits are invalid");
|
||||
}
|
||||
if (options.sfuFallback && !options.peerAssistedMedia) {
|
||||
throw new Error("SFU fallback requires peer-assisted media");
|
||||
}
|
||||
if (options.peerAssistedMedia) {
|
||||
this.hybridMediaRouter = new HybridMediaRouter({
|
||||
roomStore: options.roomStore,
|
||||
sfuFallback: options.sfuFallback,
|
||||
sendToSession: (sessionId, message) =>
|
||||
this.sendToSession(sessionId, message),
|
||||
getConnectionId: (roomId, viewerPeerId) =>
|
||||
this.connectionIdsByViewer.get(
|
||||
viewerConnectionKey(roomId, viewerPeerId),
|
||||
),
|
||||
deleteConnectionId: (roomId, viewerPeerId) =>
|
||||
this.connectionIdsByViewer.delete(
|
||||
viewerConnectionKey(roomId, viewerPeerId),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
this.upgradeHandler = (request, socket, head) => {
|
||||
let requestUrl: URL;
|
||||
@@ -157,6 +179,8 @@ export class SignalingServer {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.viewerGraceTimers.clear();
|
||||
this.hybridMediaRouter?.close();
|
||||
this.shareGenerationsByRoom.clear();
|
||||
this.options.server.off("upgrade", this.upgradeHandler);
|
||||
|
||||
for (const socket of this.webSocketServer.clients) {
|
||||
@@ -285,6 +309,21 @@ export class SignalingServer {
|
||||
|
||||
clearTimeout(state.authenticationTimer);
|
||||
this.unauthenticatedConnections -= 1;
|
||||
let shareGeneration: string | null = null;
|
||||
if (participant.role === "host" && message.role === "host") {
|
||||
const currentGeneration = this.shareGenerationsByRoom.get(
|
||||
participant.roomId,
|
||||
);
|
||||
shareGeneration =
|
||||
message.shareGeneration ?? currentGeneration ?? state.sessionId;
|
||||
if (
|
||||
currentGeneration !== undefined &&
|
||||
currentGeneration !== shareGeneration
|
||||
) {
|
||||
this.stopSharing(participant.roomId);
|
||||
}
|
||||
this.shareGenerationsByRoom.set(participant.roomId, shareGeneration);
|
||||
}
|
||||
state.authenticated = {
|
||||
roomId: participant.roomId,
|
||||
role: participant.role,
|
||||
@@ -293,29 +332,19 @@ export class SignalingServer {
|
||||
participant.expiresAt === null
|
||||
? null
|
||||
: Date.parse(participant.expiresAt),
|
||||
shareGeneration,
|
||||
};
|
||||
this.clearViewerGrace(participant.roomId, participant.peerId);
|
||||
const assignmentChanges = this.options.peerAssistedMedia
|
||||
? participant.role === "host"
|
||||
? this.peerRelayTopology.setHost(
|
||||
participant.roomId,
|
||||
participant.peerId,
|
||||
this.connectedPeerIds(participant.roomId),
|
||||
)
|
||||
: this.peerRelayTopology.addViewer(
|
||||
participant.roomId,
|
||||
participant.peerId,
|
||||
this.connectedPeerIds(participant.roomId),
|
||||
)
|
||||
: [];
|
||||
this.clearChangedConnectionIds(participant.roomId, assignmentChanges);
|
||||
const mediaAssignment = this.options.peerAssistedMedia
|
||||
? this.peerRelayTopology.getAssignment(
|
||||
participant.roomId,
|
||||
participant.peerId,
|
||||
)
|
||||
: undefined;
|
||||
if (this.options.peerAssistedMedia && !mediaAssignment) {
|
||||
const routeParticipant = {
|
||||
roomId: participant.roomId,
|
||||
role: participant.role,
|
||||
peerId: participant.peerId,
|
||||
sessionId: state.sessionId,
|
||||
};
|
||||
let hybridState;
|
||||
try {
|
||||
hybridState = this.hybridMediaRouter?.connectParticipant(routeParticipant);
|
||||
} catch {
|
||||
console.error("Peer relay topology did not assign an authenticated participant");
|
||||
this.sendError(socket, "SERVER_ERROR", "Media assignment failed");
|
||||
socket.close(1011, "Media assignment failed");
|
||||
@@ -349,11 +378,13 @@ export class SignalingServer {
|
||||
: Date.parse(participant.expiresAt),
|
||||
),
|
||||
};
|
||||
if (mediaAssignment) {
|
||||
if (hybridState) {
|
||||
this.send(socket, {
|
||||
...authenticatedMessage,
|
||||
mediaMode: "peer-assisted",
|
||||
mediaAssignment,
|
||||
mediaAssignment: hybridState.mediaAssignment,
|
||||
routeRevision: hybridState.routeRevision,
|
||||
routeAssignment: hybridState.routeAssignment,
|
||||
qualityProfileId:
|
||||
this.qualityProfileIdsByRoom.get(participant.roomId) ??
|
||||
DEFAULT_QUALITY_PROFILE_ID,
|
||||
@@ -369,24 +400,11 @@ export class SignalingServer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.options.peerAssistedMedia) {
|
||||
this.sendMediaAssignmentChanges(
|
||||
participant.roomId,
|
||||
assignmentChanges,
|
||||
participant.peerId,
|
||||
if (hybridState) {
|
||||
this.hybridMediaRouter!.completeAuthentication(
|
||||
routeParticipant,
|
||||
hybridState,
|
||||
);
|
||||
if (
|
||||
participant.role === "viewer" &&
|
||||
mediaAssignment?.parentPeerId &&
|
||||
!assignmentChanges.some(
|
||||
({ peerId }) => peerId === mediaAssignment.parentPeerId,
|
||||
)
|
||||
) {
|
||||
this.sendCurrentParentAssignment(
|
||||
participant.roomId,
|
||||
participant.peerId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (participant.role === "host") {
|
||||
@@ -477,12 +495,82 @@ export class SignalingServer {
|
||||
});
|
||||
}
|
||||
return;
|
||||
case "relay-capacity":
|
||||
if (
|
||||
!this.options.peerAssistedMedia ||
|
||||
authenticated.role !== "viewer"
|
||||
) {
|
||||
this.sendError(
|
||||
socket,
|
||||
"FORBIDDEN",
|
||||
"Only a peer-assisted viewer may advertise relay capacity",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.hybridMediaRouter!.setViewerRelayCapacity(
|
||||
{
|
||||
...authenticated,
|
||||
sessionId: this.socketStates.get(socket)!.sessionId,
|
||||
},
|
||||
message.downstreamEdges,
|
||||
);
|
||||
return;
|
||||
case "route-ready":
|
||||
if (!this.options.peerAssistedMedia) {
|
||||
this.sendError(socket, "FORBIDDEN", "Media routes are not enabled");
|
||||
return;
|
||||
}
|
||||
this.hybridMediaRouter!.handleRouteReady(
|
||||
{
|
||||
...authenticated,
|
||||
sessionId: this.socketStates.get(socket)!.sessionId,
|
||||
},
|
||||
message,
|
||||
);
|
||||
return;
|
||||
case "route-failed":
|
||||
if (!this.options.peerAssistedMedia) {
|
||||
this.sendError(socket, "FORBIDDEN", "Media routes are not enabled");
|
||||
return;
|
||||
}
|
||||
this.hybridMediaRouter!.handleRouteFailed(
|
||||
{
|
||||
...authenticated,
|
||||
sessionId: this.socketStates.get(socket)!.sessionId,
|
||||
},
|
||||
message,
|
||||
);
|
||||
return;
|
||||
case "refresh-sfu":
|
||||
if (!this.options.sfuFallback) {
|
||||
this.sendError(socket, "FORBIDDEN", "SFU fallback is not enabled");
|
||||
return;
|
||||
}
|
||||
this.hybridMediaRouter!.refreshSfu(
|
||||
{
|
||||
...authenticated,
|
||||
sessionId: this.socketStates.get(socket)!.sessionId,
|
||||
},
|
||||
message.revision,
|
||||
);
|
||||
return;
|
||||
case "stop-sharing":
|
||||
case "close-room":
|
||||
if (authenticated.role !== "host") {
|
||||
this.sendError(socket, "FORBIDDEN", "Only the host may stop sharing");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
authenticated.shareGeneration === null ||
|
||||
this.shareGenerationsByRoom.get(authenticated.roomId) !==
|
||||
authenticated.shareGeneration ||
|
||||
(message.type === "stop-sharing" &&
|
||||
message.shareGeneration !== undefined &&
|
||||
message.shareGeneration !== authenticated.shareGeneration)
|
||||
) {
|
||||
socket.close(4001, "Sharing generation replaced");
|
||||
return;
|
||||
}
|
||||
const state = this.socketStates.get(socket);
|
||||
if (state?.authenticated === authenticated) {
|
||||
this.options.roomStore.disconnectParticipant(
|
||||
@@ -580,12 +668,12 @@ export class SignalingServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentToChild = this.peerRelayTopology.isParentOf(
|
||||
const parentToChild = this.hybridMediaRouter!.isActivePeerParentOf(
|
||||
source.roomId,
|
||||
source.peerId,
|
||||
targetPeerId,
|
||||
);
|
||||
const childToParent = this.peerRelayTopology.isParentOf(
|
||||
const childToParent = this.hybridMediaRouter!.isActivePeerParentOf(
|
||||
source.roomId,
|
||||
targetPeerId,
|
||||
source.peerId,
|
||||
@@ -629,7 +717,7 @@ export class SignalingServer {
|
||||
const targetPeerId = message.targetPeerId;
|
||||
if (
|
||||
!targetPeerId ||
|
||||
!this.peerRelayTopology.isParentOf(
|
||||
!this.hybridMediaRouter!.isActivePeerParentOf(
|
||||
source.roomId,
|
||||
targetPeerId,
|
||||
source.peerId,
|
||||
@@ -694,6 +782,7 @@ export class SignalingServer {
|
||||
if (!disconnected) {
|
||||
return;
|
||||
}
|
||||
this.hybridMediaRouter?.disconnectParticipant(disconnected.roomId);
|
||||
if (disconnected.role === "host") {
|
||||
for (const viewer of this.options.roomStore.getConnectedViewers(
|
||||
disconnected.roomId,
|
||||
@@ -715,19 +804,10 @@ export class SignalingServer {
|
||||
this.connectionIdsByViewer.delete(
|
||||
viewerConnectionKey(disconnected.roomId, disconnected.peerId),
|
||||
);
|
||||
if (this.options.peerAssistedMedia) {
|
||||
const assignmentChanges = this.peerRelayTopology.removeViewer(
|
||||
if (this.hybridMediaRouter) {
|
||||
this.hybridMediaRouter.removeViewer(
|
||||
disconnected.roomId,
|
||||
disconnected.peerId,
|
||||
this.connectedPeerIds(disconnected.roomId),
|
||||
);
|
||||
this.clearChangedConnectionIds(
|
||||
disconnected.roomId,
|
||||
assignmentChanges,
|
||||
);
|
||||
this.sendMediaAssignmentChanges(
|
||||
disconnected.roomId,
|
||||
assignmentChanges,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -746,6 +826,7 @@ export class SignalingServer {
|
||||
|
||||
private stopSharing(roomId: string): void {
|
||||
this.clearRoomConnectionIds(roomId);
|
||||
this.hybridMediaRouter?.stopRoom(roomId);
|
||||
for (const viewer of this.options.roomStore.getConnectedViewers(roomId)) {
|
||||
this.sendToSession(viewer.sessionId, { type: "sharing-stopped" });
|
||||
this.sendToSession(viewer.sessionId, { type: "host-status", online: false });
|
||||
@@ -759,8 +840,9 @@ export class SignalingServer {
|
||||
}
|
||||
this.clearRoomGraceTimers(roomId);
|
||||
this.clearRoomConnectionIds(roomId);
|
||||
this.peerRelayTopology.deleteRoom(roomId);
|
||||
this.hybridMediaRouter?.deleteRoom(roomId);
|
||||
this.qualityProfileIdsByRoom.delete(roomId);
|
||||
this.shareGenerationsByRoom.delete(roomId);
|
||||
for (const sessionId of abandoned.sessionIds) {
|
||||
const socket = this.socketsBySessionId.get(sessionId);
|
||||
if (!socket) {
|
||||
@@ -775,8 +857,9 @@ export class SignalingServer {
|
||||
for (const expired of this.options.roomStore.expireRooms(this.now())) {
|
||||
this.clearRoomGraceTimers(expired.roomId);
|
||||
this.clearRoomConnectionIds(expired.roomId);
|
||||
this.peerRelayTopology.deleteRoom(expired.roomId);
|
||||
this.hybridMediaRouter?.deleteRoom(expired.roomId);
|
||||
this.qualityProfileIdsByRoom.delete(expired.roomId);
|
||||
this.shareGenerationsByRoom.delete(expired.roomId);
|
||||
for (const sessionId of expired.sessionIds) {
|
||||
const socket = this.socketsBySessionId.get(sessionId);
|
||||
if (!socket) {
|
||||
@@ -847,19 +930,6 @@ export class SignalingServer {
|
||||
);
|
||||
}
|
||||
|
||||
private connectedPeerIds(roomId: string): Set<string> {
|
||||
const peerIds = new Set(
|
||||
this.options.roomStore
|
||||
.getConnectedViewers(roomId)
|
||||
.map((viewer) => viewer.peerId),
|
||||
);
|
||||
const host = this.options.roomStore.getConnectedHost(roomId);
|
||||
if (host) {
|
||||
peerIds.add(host.peerId);
|
||||
}
|
||||
return peerIds;
|
||||
}
|
||||
|
||||
private connectedPeer(roomId: string, peerId: string) {
|
||||
const host = this.options.roomStore.getConnectedHost(roomId);
|
||||
if (host?.peerId === peerId) {
|
||||
@@ -868,66 +938,6 @@ export class SignalingServer {
|
||||
return this.options.roomStore.getConnectedViewer(roomId, peerId);
|
||||
}
|
||||
|
||||
private clearChangedConnectionIds(
|
||||
roomId: string,
|
||||
changes: readonly MediaAssignmentChange[],
|
||||
): void {
|
||||
for (const change of changes) {
|
||||
if (
|
||||
change.previousParentPeerId !== undefined &&
|
||||
change.previousParentPeerId !==
|
||||
change.mediaAssignment.parentPeerId
|
||||
) {
|
||||
this.connectionIdsByViewer.delete(
|
||||
viewerConnectionKey(roomId, change.peerId),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendMediaAssignmentChanges(
|
||||
roomId: string,
|
||||
changes: readonly MediaAssignmentChange[],
|
||||
excludedPeerId?: string,
|
||||
): void {
|
||||
for (const change of changes) {
|
||||
if (change.peerId === excludedPeerId) {
|
||||
continue;
|
||||
}
|
||||
const peer = this.connectedPeer(roomId, change.peerId);
|
||||
if (peer) {
|
||||
this.sendToSession(peer.sessionId, {
|
||||
type: "media-assignment",
|
||||
mediaAssignment: change.mediaAssignment,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendCurrentParentAssignment(
|
||||
roomId: string,
|
||||
childPeerId: string,
|
||||
): void {
|
||||
const parentPeerId = this.peerRelayTopology.getAssignment(
|
||||
roomId,
|
||||
childPeerId,
|
||||
)?.parentPeerId;
|
||||
if (!parentPeerId) {
|
||||
return;
|
||||
}
|
||||
const parent = this.connectedPeer(roomId, parentPeerId);
|
||||
const parentAssignment = this.peerRelayTopology.getAssignment(
|
||||
roomId,
|
||||
parentPeerId,
|
||||
);
|
||||
if (parent && parentAssignment) {
|
||||
this.sendToSession(parent.sessionId, {
|
||||
type: "media-assignment",
|
||||
mediaAssignment: parentAssignment,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sendToSession(sessionId: string, message: ServerMessage): void {
|
||||
const socket = this.socketsBySessionId.get(sessionId);
|
||||
if (socket) {
|
||||
|
||||
+97
-1
@@ -3,6 +3,8 @@ import { z } from "zod";
|
||||
export const MAX_VIEWERS_PER_ROOM_LIMIT = 16;
|
||||
export const MAX_SIGNAL_BYTES = 64 * 1024;
|
||||
export const ROOM_CODE_LENGTH = 12;
|
||||
export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER;
|
||||
export const MAX_SFU_TOKEN_LENGTH = 8 * 1024;
|
||||
|
||||
const opaqueIdSchema = z
|
||||
.string()
|
||||
@@ -33,6 +35,12 @@ export const qualityProfileIdSchema = z.enum([
|
||||
export type QualityProfileId = z.infer<typeof qualityProfileIdSchema>;
|
||||
export const DEFAULT_QUALITY_PROFILE_ID: QualityProfileId = "1080p60";
|
||||
|
||||
export const relayDownstreamEdgesSchema = z.union([
|
||||
z.literal(0),
|
||||
z.literal(1),
|
||||
]);
|
||||
export type RelayDownstreamEdges = z.infer<typeof relayDownstreamEdgesSchema>;
|
||||
|
||||
const iceServerSchema = z
|
||||
.object({
|
||||
urls: z.union([
|
||||
@@ -95,6 +103,43 @@ export const mediaAssignmentSchema = z
|
||||
.strict();
|
||||
export type MediaAssignment = z.infer<typeof mediaAssignmentSchema>;
|
||||
|
||||
export const mediaRouteRevisionSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_MEDIA_ROUTE_REVISION);
|
||||
|
||||
export const mediaRoutePhaseSchema = z.enum(["prepare", "active"]);
|
||||
export type MediaRoutePhase = z.infer<typeof mediaRoutePhaseSchema>;
|
||||
|
||||
export const mediaRouteUpstreamSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("none") }).strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("peer"),
|
||||
peerId: opaqueIdSchema,
|
||||
})
|
||||
.strict(),
|
||||
z.object({ kind: z.literal("sfu") }).strict(),
|
||||
]);
|
||||
export type MediaRouteUpstream = z.infer<typeof mediaRouteUpstreamSchema>;
|
||||
|
||||
export const sfuPublicationGenerationSchema = opaqueIdSchema;
|
||||
|
||||
export const participantRouteAssignmentSchema = z
|
||||
.object({
|
||||
upstream: mediaRouteUpstreamSchema,
|
||||
childPeerIds: z
|
||||
.array(opaqueIdSchema)
|
||||
.max(2)
|
||||
.refine((peerIds) => new Set(peerIds).size === peerIds.length),
|
||||
sfuPublicationGeneration: sfuPublicationGenerationSchema.nullable(),
|
||||
})
|
||||
.strict();
|
||||
export type ParticipantRouteAssignment = z.infer<
|
||||
typeof participantRouteAssignmentSchema
|
||||
>;
|
||||
|
||||
const authenticateMessageSchema = z.discriminatedUnion("role", [
|
||||
z
|
||||
.object({
|
||||
@@ -103,6 +148,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [
|
||||
role: z.literal("host"),
|
||||
token: tokenSchema,
|
||||
clientId: opaqueIdSchema,
|
||||
shareGeneration: opaqueIdSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
@@ -139,7 +185,39 @@ export const clientMessageSchema = z.union([
|
||||
qualityProfileId: qualityProfileIdSchema,
|
||||
})
|
||||
.strict(),
|
||||
z.object({ type: z.literal("stop-sharing") }).strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("relay-capacity"),
|
||||
downstreamEdges: relayDownstreamEdgesSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("route-ready"),
|
||||
revision: mediaRouteRevisionSchema,
|
||||
phase: mediaRoutePhaseSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("route-failed"),
|
||||
revision: mediaRouteRevisionSchema,
|
||||
phase: mediaRoutePhaseSchema,
|
||||
connectionId: opaqueIdSchema.nullable(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("refresh-sfu"),
|
||||
revision: mediaRouteRevisionSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("stop-sharing"),
|
||||
shareGeneration: opaqueIdSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
// Kept as a compatibility alias while previously deployed clients age out.
|
||||
z.object({ type: z.literal("close-room") }).strict(),
|
||||
z.object({ type: z.literal("abandon-room") }).strict(),
|
||||
@@ -177,6 +255,8 @@ const authenticatedMessageSchema = z.union([
|
||||
...authenticatedMessageShape,
|
||||
mediaMode: z.literal("peer-assisted"),
|
||||
mediaAssignment: mediaAssignmentSchema,
|
||||
routeRevision: mediaRouteRevisionSchema,
|
||||
routeAssignment: participantRouteAssignmentSchema,
|
||||
qualityProfileId: qualityProfileIdSchema,
|
||||
})
|
||||
.strict(),
|
||||
@@ -217,6 +297,22 @@ export const serverMessageSchema = z.union([
|
||||
mediaAssignment: mediaAssignmentSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("route-update"),
|
||||
revision: mediaRouteRevisionSchema,
|
||||
phase: mediaRoutePhaseSchema,
|
||||
assignment: participantRouteAssignmentSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("sfu-config"),
|
||||
revision: mediaRouteRevisionSchema,
|
||||
url: z.string().url().max(2048),
|
||||
token: z.string().min(1).max(MAX_SFU_TOKEN_LENGTH),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("quality-profile"),
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TokenVerifier } from "livekit-server-sdk";
|
||||
|
||||
import { LiveKitTokenIssuer } from "../src/server/livekit-token.ts";
|
||||
|
||||
const apiKey = "test-api-key";
|
||||
const apiSecret = "s".repeat(32);
|
||||
const publicationGeneration = "publication_12345678";
|
||||
const rootPeerId = "viewer_root_12345678";
|
||||
|
||||
function issuer(
|
||||
maxViewersPerRoom = 8,
|
||||
maxSfuRootsPerRoom = 2,
|
||||
): LiveKitTokenIssuer {
|
||||
return new LiveKitTokenIssuer({
|
||||
apiKey,
|
||||
apiSecret,
|
||||
maxViewersPerRoom,
|
||||
maxSfuRootsPerRoom,
|
||||
});
|
||||
}
|
||||
|
||||
describe("LiveKitTokenIssuer", () => {
|
||||
it("issues a generation-bound host token limited to screen sharing", async () => {
|
||||
const claims = await new TokenVerifier(apiKey, apiSecret).verify(
|
||||
await issuer().issueToken({
|
||||
roomId: "42",
|
||||
role: "host",
|
||||
peerId: "host_peer_12345678",
|
||||
publicationGeneration,
|
||||
allowlistedRootPeerIds: [rootPeerId],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(claims.sub).toBe("host");
|
||||
expect(claims.exp! - claims.nbf!).toBe(5 * 60);
|
||||
expect(claims.video).toMatchObject({
|
||||
roomJoin: true,
|
||||
room: `screener-42-${publicationGeneration}`,
|
||||
canPublish: true,
|
||||
canSubscribe: false,
|
||||
canPublishData: false,
|
||||
canUpdateOwnMetadata: false,
|
||||
canPublishSources: ["screen_share", "screen_share_audio"],
|
||||
});
|
||||
expect(claims.roomConfig).toMatchObject({
|
||||
name: `screener-42-${publicationGeneration}`,
|
||||
maxParticipants: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("issues a subscribe-only token to a current fallback root", async () => {
|
||||
const claims = await new TokenVerifier(apiKey, apiSecret).verify(
|
||||
await issuer(3).issueToken({
|
||||
roomId: "7",
|
||||
role: "viewer",
|
||||
peerId: rootPeerId,
|
||||
publicationGeneration,
|
||||
allowlistedRootPeerIds: [rootPeerId],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(claims.sub).toBe(`viewer:${rootPeerId}`);
|
||||
expect(claims.video).toMatchObject({
|
||||
roomJoin: true,
|
||||
room: `screener-7-${publicationGeneration}`,
|
||||
canPublish: false,
|
||||
canSubscribe: true,
|
||||
canPublishData: false,
|
||||
canUpdateOwnMetadata: false,
|
||||
});
|
||||
expect(claims.video?.canPublishSources).toBeUndefined();
|
||||
expect(claims.roomConfig?.maxParticipants).toBe(4);
|
||||
});
|
||||
|
||||
it("does not issue viewer tokens outside the current root allowlist", async () => {
|
||||
await expect(
|
||||
issuer().issueToken({
|
||||
roomId: "7",
|
||||
role: "viewer",
|
||||
peerId: "viewer_other_12345678",
|
||||
publicationGeneration,
|
||||
allowlistedRootPeerIds: [rootPeerId],
|
||||
}),
|
||||
).rejects.toThrow("viewer is not an allowlisted SFU root");
|
||||
});
|
||||
|
||||
it("rejects duplicate, excessive, and malformed root allowlists", async () => {
|
||||
const tokenIssuer = issuer(8, 1);
|
||||
const request = {
|
||||
roomId: "7",
|
||||
role: "host" as const,
|
||||
peerId: "host_peer_12345678",
|
||||
publicationGeneration,
|
||||
};
|
||||
|
||||
await expect(
|
||||
tokenIssuer.issueToken({
|
||||
...request,
|
||||
allowlistedRootPeerIds: [rootPeerId, rootPeerId],
|
||||
}),
|
||||
).rejects.toThrow("root allowlist is invalid");
|
||||
await expect(
|
||||
issuer().issueToken({
|
||||
...request,
|
||||
allowlistedRootPeerIds: [rootPeerId, rootPeerId],
|
||||
}),
|
||||
).rejects.toThrow("root allowlist contains duplicates");
|
||||
await expect(
|
||||
issuer().issueToken({
|
||||
...request,
|
||||
allowlistedRootPeerIds: ["not valid"],
|
||||
}),
|
||||
).rejects.toThrow("root allowlist is invalid");
|
||||
});
|
||||
|
||||
it("isolates publication generations in separate LiveKit rooms", async () => {
|
||||
const tokenIssuer = issuer();
|
||||
const verifier = new TokenVerifier(apiKey, apiSecret);
|
||||
const baseRequest = {
|
||||
roomId: "7",
|
||||
role: "host" as const,
|
||||
peerId: "host_peer_12345678",
|
||||
allowlistedRootPeerIds: [rootPeerId],
|
||||
};
|
||||
const first = await verifier.verify(
|
||||
await tokenIssuer.issueToken({
|
||||
...baseRequest,
|
||||
publicationGeneration: "generation_first_12345678",
|
||||
}),
|
||||
);
|
||||
const second = await verifier.verify(
|
||||
await tokenIssuer.issueToken({
|
||||
...baseRequest,
|
||||
publicationGeneration: "generation_second_12345678",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(first.video?.room).not.toBe(second.video?.room);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MediaRouteController,
|
||||
type PrepareMediaRouteInput,
|
||||
} from "../src/server/media-route-controller.ts";
|
||||
import type { ParticipantRouteAssignment } from "../src/shared/protocol.ts";
|
||||
|
||||
const HOST = "host_12345678";
|
||||
const DIRECT_ROOT = "viewer_direct_12345678";
|
||||
const SFU_ROOT_A = "viewer_sfu_a_12345678";
|
||||
const SFU_ROOT_B = "viewer_sfu_b_12345678";
|
||||
const GENERATION_A = "generation_a_12345678";
|
||||
const GENERATION_B = "generation_b_12345678";
|
||||
|
||||
function assignment(
|
||||
upstream: ParticipantRouteAssignment["upstream"],
|
||||
childPeerIds: string[] = [],
|
||||
sfuPublicationGeneration: string | null = null,
|
||||
): ParticipantRouteAssignment {
|
||||
return { upstream, childPeerIds, sfuPublicationGeneration };
|
||||
}
|
||||
|
||||
function directAssignments(): Map<string, ParticipantRouteAssignment> {
|
||||
return new Map([
|
||||
[HOST, assignment({ kind: "none" }, [DIRECT_ROOT, SFU_ROOT_A])],
|
||||
[DIRECT_ROOT, assignment({ kind: "peer", peerId: HOST })],
|
||||
[SFU_ROOT_A, assignment({ kind: "peer", peerId: HOST })],
|
||||
[SFU_ROOT_B, assignment({ kind: "none" })],
|
||||
]);
|
||||
}
|
||||
|
||||
function hybridPlan(
|
||||
generation = GENERATION_A,
|
||||
rootPeerId = SFU_ROOT_A,
|
||||
): PrepareMediaRouteInput {
|
||||
const otherRootPeerId =
|
||||
rootPeerId === SFU_ROOT_A ? SFU_ROOT_B : SFU_ROOT_A;
|
||||
return {
|
||||
assignments: new Map([
|
||||
[
|
||||
HOST,
|
||||
assignment({ kind: "none" }, [DIRECT_ROOT], generation),
|
||||
],
|
||||
[DIRECT_ROOT, assignment({ kind: "peer", peerId: HOST })],
|
||||
[rootPeerId, assignment({ kind: "sfu" })],
|
||||
[otherRootPeerId, assignment({ kind: "none" })],
|
||||
]),
|
||||
expectedParticipantIds: new Set([HOST, rootPeerId]),
|
||||
sfuPublicationGeneration: generation,
|
||||
sfuRootPeerIds: [rootPeerId],
|
||||
};
|
||||
}
|
||||
|
||||
function controller(
|
||||
assignments = directAssignments(),
|
||||
): MediaRouteController {
|
||||
return new MediaRouteController({ hostPeerId: HOST, assignments });
|
||||
}
|
||||
|
||||
function chainedAssignments(): Map<string, ParticipantRouteAssignment> {
|
||||
return new Map([
|
||||
[HOST, assignment({ kind: "none" }, [DIRECT_ROOT])],
|
||||
[
|
||||
DIRECT_ROOT,
|
||||
assignment({ kind: "peer", peerId: HOST }, [SFU_ROOT_A]),
|
||||
],
|
||||
[SFU_ROOT_A, assignment({ kind: "peer", peerId: DIRECT_ROOT })],
|
||||
[SFU_ROOT_B, assignment({ kind: "none" })],
|
||||
]);
|
||||
}
|
||||
|
||||
describe("MediaRouteController", () => {
|
||||
it("atomically commits only after every expected participant is ready", () => {
|
||||
const routes = controller();
|
||||
const revision = routes.prepare(hybridPlan())!;
|
||||
|
||||
expect(revision).toBe(1);
|
||||
expect(routes.getActiveRoute().sfu.publicationGeneration).toBeNull();
|
||||
expect(routes.ready(HOST, revision, "prepare")).toBe(true);
|
||||
expect(routes.ready(HOST, revision, "prepare")).toBe(false);
|
||||
expect(routes.commit(revision)).toBe(false);
|
||||
expect(routes.getActiveRoute().revision).toBe(0);
|
||||
|
||||
expect(routes.ready(SFU_ROOT_A, revision, "prepare")).toBe(true);
|
||||
expect(routes.getActiveRoute().sfu.publicationGeneration).toBeNull();
|
||||
expect(routes.commit(revision)).toBe(true);
|
||||
expect(routes.getPendingRoute()).toBeUndefined();
|
||||
expect(routes.getActiveRoute()).toMatchObject({
|
||||
revision,
|
||||
sfu: {
|
||||
publicationGeneration: GENERATION_A,
|
||||
rootPeerIds: [SFU_ROOT_A],
|
||||
},
|
||||
});
|
||||
expect(routes.hostActiveMediaEdges()).toBe(2);
|
||||
});
|
||||
|
||||
it("allows only one pending plan and ignores stale or wrong-phase messages", () => {
|
||||
const routes = controller();
|
||||
const revision = routes.prepare(hybridPlan())!;
|
||||
|
||||
expect(routes.prepare(hybridPlan(GENERATION_B, SFU_ROOT_B))).toBeUndefined();
|
||||
expect(routes.ready(HOST, revision - 1, "prepare")).toBe(false);
|
||||
expect(routes.ready(HOST, revision, "active")).toBe(false);
|
||||
expect(routes.commit(revision - 1)).toBe(false);
|
||||
expect(routes.abort(revision - 1)).toBe(false);
|
||||
expect(routes.getPendingRoute()?.route.revision).toBe(revision);
|
||||
expect(routes.getActiveRoute().revision).toBe(0);
|
||||
});
|
||||
|
||||
it("aborts to the unchanged active topology with a newer revision", () => {
|
||||
const routes = controller();
|
||||
const before = routes.getActiveRoute();
|
||||
const preparedRevision = routes.prepare(hybridPlan())!;
|
||||
|
||||
expect(routes.abort(preparedRevision)).toBe(true);
|
||||
const after = routes.getActiveRoute();
|
||||
expect(after.revision).toBe(preparedRevision + 1);
|
||||
expect(after.assignments).toEqual(before.assignments);
|
||||
expect(after.sfu).toEqual(before.sfu);
|
||||
expect(routes.getPendingRoute()).toBeUndefined();
|
||||
expect(routes.abort(preparedRevision)).toBe(false);
|
||||
|
||||
expect(routes.prepare(hybridPlan())).toBe(after.revision + 1);
|
||||
});
|
||||
|
||||
it("reconciles complete baseline snapshots without resetting revisions", () => {
|
||||
const routes = controller();
|
||||
const reconciledRevision = routes.reconcileBaseline({
|
||||
assignments: chainedAssignments(),
|
||||
});
|
||||
|
||||
expect(reconciledRevision).toBe(1);
|
||||
expect(routes.getActiveRoute().revision).toBe(1);
|
||||
expect(
|
||||
routes.getActiveRoute().assignments.get(SFU_ROOT_A)?.upstream,
|
||||
).toEqual({ kind: "peer", peerId: DIRECT_ROOT });
|
||||
expect(
|
||||
routes.reconcileBaseline({ assignments: chainedAssignments() }),
|
||||
).toBeUndefined();
|
||||
expect(routes.getActiveRoute().revision).toBe(1);
|
||||
|
||||
const preparedRevision = routes.prepare(hybridPlan())!;
|
||||
expect(
|
||||
routes.reconcileBaseline({ assignments: directAssignments() }),
|
||||
).toBeUndefined();
|
||||
expect(routes.getActiveRoute().revision).toBe(1);
|
||||
expect(routes.abort(preparedRevision)).toBe(true);
|
||||
expect(routes.getActiveRoute().revision).toBe(3);
|
||||
|
||||
expect(
|
||||
routes.reconcileBaseline({ assignments: directAssignments() }),
|
||||
).toBe(4);
|
||||
expect(routes.getActiveRoute().revision).toBe(4);
|
||||
expect(routes.hostActiveMediaEdges()).toBe(2);
|
||||
});
|
||||
|
||||
it("preserves active SFU state while reconciling peer topology", () => {
|
||||
const activePlan = hybridPlan(GENERATION_A, SFU_ROOT_A);
|
||||
const routes = new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: activePlan.assignments,
|
||||
revision: 6,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A],
|
||||
});
|
||||
const assignments = new Map(activePlan.assignments);
|
||||
assignments.set(
|
||||
DIRECT_ROOT,
|
||||
assignment({ kind: "peer", peerId: HOST }, [SFU_ROOT_B]),
|
||||
);
|
||||
assignments.set(
|
||||
SFU_ROOT_B,
|
||||
assignment({ kind: "peer", peerId: DIRECT_ROOT }),
|
||||
);
|
||||
|
||||
expect(routes.reconcileBaseline({ assignments })).toBe(7);
|
||||
expect(routes.getActiveRoute().sfu).toEqual({
|
||||
publicationGeneration: GENERATION_A,
|
||||
rootPeerIds: [SFU_ROOT_A],
|
||||
});
|
||||
expect(routes.hostActiveMediaEdges()).toBe(2);
|
||||
});
|
||||
|
||||
it("never overlaps old and new SFU publication generations", () => {
|
||||
const initialPlan = hybridPlan(GENERATION_A, SFU_ROOT_A);
|
||||
const routes = new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: initialPlan.assignments,
|
||||
revision: 4,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A],
|
||||
});
|
||||
const preparedRevision = routes.prepare(
|
||||
hybridPlan(GENERATION_B, SFU_ROOT_B),
|
||||
)!;
|
||||
|
||||
expect(routes.getActiveRoute().sfu).toEqual({
|
||||
publicationGeneration: GENERATION_A,
|
||||
rootPeerIds: [SFU_ROOT_A],
|
||||
});
|
||||
expect(routes.getPendingRoute()?.route.sfu).toEqual({
|
||||
publicationGeneration: GENERATION_B,
|
||||
rootPeerIds: [SFU_ROOT_B],
|
||||
});
|
||||
expect(routes.hostActiveMediaEdges()).toBe(2);
|
||||
|
||||
routes.ready(HOST, preparedRevision, "prepare");
|
||||
routes.ready(SFU_ROOT_B, preparedRevision, "prepare");
|
||||
expect(routes.commit(preparedRevision)).toBe(true);
|
||||
expect(routes.getActiveRoute().sfu).toEqual({
|
||||
publicationGeneration: GENERATION_B,
|
||||
rootPeerIds: [SFU_ROOT_B],
|
||||
});
|
||||
expect(routes.hostActiveMediaEdges()).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects host fanout above two and inconsistent SFU ownership", () => {
|
||||
const tooManyHostEdges = new Map(hybridPlan().assignments);
|
||||
tooManyHostEdges.set(
|
||||
HOST,
|
||||
assignment(
|
||||
{ kind: "none" },
|
||||
[DIRECT_ROOT, SFU_ROOT_B],
|
||||
GENERATION_A,
|
||||
),
|
||||
);
|
||||
tooManyHostEdges.set(
|
||||
SFU_ROOT_B,
|
||||
assignment({ kind: "peer", peerId: HOST }),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: tooManyHostEdges,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A],
|
||||
}),
|
||||
).toThrow("Host active media edge budget exceeded");
|
||||
|
||||
const nonHostPublisher = directAssignments();
|
||||
nonHostPublisher.set(
|
||||
DIRECT_ROOT,
|
||||
assignment({ kind: "peer", peerId: HOST }, [], GENERATION_A),
|
||||
);
|
||||
expect(() => controller(nonHostPublisher)).toThrow(
|
||||
"Only the host may own an SFU publication generation",
|
||||
);
|
||||
|
||||
const missingRoot = hybridPlan().assignments;
|
||||
expect(() =>
|
||||
new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: missingRoot,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_B],
|
||||
}),
|
||||
).toThrow("Every SFU upstream must belong to the root allowlist");
|
||||
|
||||
const generationWithoutRoots = directAssignments();
|
||||
generationWithoutRoots.set(
|
||||
HOST,
|
||||
assignment(
|
||||
{ kind: "none" },
|
||||
[DIRECT_ROOT, SFU_ROOT_A],
|
||||
GENERATION_A,
|
||||
),
|
||||
);
|
||||
expect(() =>
|
||||
new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: generationWithoutRoots,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
}),
|
||||
).toThrow("SFU roots and publication generation must be active together");
|
||||
});
|
||||
|
||||
it("rejects reciprocal peer cycles", () => {
|
||||
const assignments = new Map<string, ParticipantRouteAssignment>([
|
||||
[HOST, assignment({ kind: "none" })],
|
||||
[
|
||||
SFU_ROOT_A,
|
||||
assignment({ kind: "peer", peerId: SFU_ROOT_B }, [SFU_ROOT_B]),
|
||||
],
|
||||
[
|
||||
SFU_ROOT_B,
|
||||
assignment({ kind: "peer", peerId: SFU_ROOT_A }, [SFU_ROOT_A]),
|
||||
],
|
||||
]);
|
||||
|
||||
expect(() => controller(assignments)).toThrow(
|
||||
"Peer media route contains a cycle",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves budgets and monotonic revisions across mixed sequences", () => {
|
||||
const routes = controller();
|
||||
let previousRevision = routes.getActiveRoute().revision;
|
||||
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
const plan =
|
||||
index % 2 === 0
|
||||
? hybridPlan(GENERATION_A, SFU_ROOT_A)
|
||||
: {
|
||||
assignments: directAssignments(),
|
||||
expectedParticipantIds: new Set([HOST]),
|
||||
};
|
||||
const revision = routes.prepare(plan)!;
|
||||
expect(revision).toBeGreaterThan(previousRevision);
|
||||
expect(routes.prepare(plan)).toBeUndefined();
|
||||
expect(routes.ready(HOST, revision - 1, "prepare")).toBe(false);
|
||||
expect(routes.commit(revision - 1)).toBe(false);
|
||||
expect(routes.hostActiveMediaEdges()).toBeLessThanOrEqual(2);
|
||||
|
||||
expect(routes.ready(HOST, revision, "prepare")).toBe(true);
|
||||
expect(routes.ready(HOST, revision, "prepare")).toBe(false);
|
||||
if (index % 3 === 0) {
|
||||
expect(routes.abort(revision)).toBe(true);
|
||||
} else {
|
||||
if (plan.expectedParticipantIds.has(SFU_ROOT_A)) {
|
||||
expect(routes.ready(SFU_ROOT_A, revision, "prepare")).toBe(true);
|
||||
}
|
||||
expect(routes.commit(revision)).toBe(true);
|
||||
}
|
||||
|
||||
const active = routes.getActiveRoute();
|
||||
expect(active.revision).toBeGreaterThan(previousRevision);
|
||||
expect(routes.getPendingRoute()).toBeUndefined();
|
||||
expect(routes.hostActiveMediaEdges()).toBeLessThanOrEqual(2);
|
||||
previousRevision = active.revision;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,24 @@ function connected(...peerIds: string[]): Set<string> {
|
||||
return new Set(peerIds);
|
||||
}
|
||||
|
||||
function addRelayViewer(
|
||||
topology: PeerRelayTopology,
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
) {
|
||||
const changes = topology.addViewer(roomId, peerId, connectedPeerIds);
|
||||
return [
|
||||
...changes,
|
||||
...topology.setViewerRelayCapacity(
|
||||
roomId,
|
||||
peerId,
|
||||
1,
|
||||
connectedPeerIds,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function expectValidTree(
|
||||
topology: PeerRelayTopology,
|
||||
roomId: string,
|
||||
@@ -42,6 +60,98 @@ function expectValidTree(
|
||||
}
|
||||
|
||||
describe("PeerRelayTopology", () => {
|
||||
it("keeps viewers as leaves until a relay slot is explicitly advertised", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = connected("host", "viewer-a", "viewer-b", "viewer-c");
|
||||
topology.setHost("room", "host", peers);
|
||||
topology.addViewer("room", "viewer-a", peers);
|
||||
topology.addViewer("room", "viewer-b", peers);
|
||||
topology.addViewer("room", "viewer-c", peers);
|
||||
|
||||
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBeNull();
|
||||
expect(
|
||||
topology.setViewerRelayCapacity("room", "viewer-a", 0, peers),
|
||||
).toEqual([]);
|
||||
|
||||
const changes = topology.setViewerRelayCapacity(
|
||||
"room",
|
||||
"viewer-a",
|
||||
1,
|
||||
peers,
|
||||
);
|
||||
expect(changes.map(({ peerId }) => peerId).sort()).toEqual([
|
||||
"viewer-a",
|
||||
"viewer-c",
|
||||
]);
|
||||
expect(topology.getAssignment("room", "viewer-a")).toEqual({
|
||||
parentPeerId: "host",
|
||||
childPeerIds: ["viewer-c"],
|
||||
});
|
||||
expect(topology.getAssignment("room", "viewer-b")?.parentPeerId).toBe(
|
||||
"host",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not migrate healthy edges when a relay withdraws capacity", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = connected("host", "viewer-a", "viewer-b", "viewer-c", "viewer-d");
|
||||
topology.setHost("room", "host", peers);
|
||||
topology.addViewer("room", "viewer-a", peers);
|
||||
topology.addViewer("room", "viewer-b", peers);
|
||||
topology.setViewerRelayCapacity("room", "viewer-a", 1, peers);
|
||||
topology.addViewer("room", "viewer-c", peers);
|
||||
|
||||
expect(
|
||||
topology.setViewerRelayCapacity("room", "viewer-a", 0, peers),
|
||||
).toEqual([]);
|
||||
topology.addViewer("room", "viewer-d", peers);
|
||||
|
||||
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBe(
|
||||
"viewer-a",
|
||||
);
|
||||
expect(topology.getAssignment("room", "viewer-d")?.parentPeerId).toBeNull();
|
||||
});
|
||||
|
||||
it("does not reassign a failed edge through a viewer leaf", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = connected("host", "viewer-a", "viewer-b", "viewer-c");
|
||||
topology.setHost("room", "host", peers);
|
||||
topology.addViewer("room", "viewer-a", peers);
|
||||
topology.addViewer("room", "viewer-b", peers);
|
||||
topology.setViewerRelayCapacity("room", "viewer-a", 1, peers);
|
||||
topology.addViewer("room", "viewer-c", peers);
|
||||
|
||||
expect(
|
||||
topology.reassignViewer(
|
||||
"room",
|
||||
"viewer-c",
|
||||
peers,
|
||||
new Set(["viewer-a"]),
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBe(
|
||||
"viewer-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a reconnecting viewer to advertise capacity again", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = connected("host", "viewer-a", "viewer-b", "viewer-c", "viewer-d");
|
||||
topology.setHost("room", "host", peers);
|
||||
topology.addViewer("room", "viewer-a", peers);
|
||||
topology.addViewer("room", "viewer-b", peers);
|
||||
topology.setViewerRelayCapacity("room", "viewer-a", 1, peers);
|
||||
topology.addViewer("room", "viewer-c", peers);
|
||||
|
||||
topology.addViewer("room", "viewer-a", peers);
|
||||
topology.addViewer("room", "viewer-d", peers);
|
||||
|
||||
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBe(
|
||||
"viewer-a",
|
||||
);
|
||||
expect(topology.getAssignment("room", "viewer-d")?.parentPeerId).toBeNull();
|
||||
});
|
||||
|
||||
it("fills the shallowest stable slots with bounded fanout", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const roomId = "room";
|
||||
@@ -50,7 +160,7 @@ describe("PeerRelayTopology", () => {
|
||||
|
||||
const viewers = ["viewer-a", "viewer-b", "viewer-c", "viewer-d", "viewer-e"];
|
||||
for (const viewer of viewers) {
|
||||
topology.addViewer(roomId, viewer, connected(host, ...viewers));
|
||||
addRelayViewer(topology, roomId, viewer, connected(host, ...viewers));
|
||||
}
|
||||
|
||||
expect(topology.getAssignment(roomId, host)).toEqual({
|
||||
@@ -81,7 +191,8 @@ describe("PeerRelayTopology", () => {
|
||||
);
|
||||
topology.setHost(roomId, hostPeerId, connected(hostPeerId));
|
||||
for (const peerId of viewerPeerIds) {
|
||||
topology.addViewer(
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
peerId,
|
||||
connected(hostPeerId, ...viewerPeerIds),
|
||||
@@ -91,23 +202,63 @@ describe("PeerRelayTopology", () => {
|
||||
expect(expectValidTree(topology, roomId, hostPeerId, viewerPeerIds)).toBe(4);
|
||||
});
|
||||
|
||||
it("uses the same depth-four bound for initial admission", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const roomId = "room";
|
||||
const peerIds = [
|
||||
"host",
|
||||
"viewer-a",
|
||||
"viewer-b",
|
||||
"viewer-c",
|
||||
"viewer-d",
|
||||
"viewer-e",
|
||||
"viewer-f",
|
||||
];
|
||||
const peers = connected(...peerIds);
|
||||
topology.setHost(roomId, "host", peers);
|
||||
topology.addViewer(roomId, "viewer-a", peers);
|
||||
topology.setViewerRelayCapacity(roomId, "viewer-a", 1, peers);
|
||||
topology.addViewer(roomId, "viewer-b", peers);
|
||||
for (const peerId of ["viewer-c", "viewer-d", "viewer-e"]) {
|
||||
topology.addViewer(roomId, peerId, peers);
|
||||
topology.setViewerRelayCapacity(roomId, peerId, 1, peers);
|
||||
}
|
||||
topology.addViewer(roomId, "viewer-f", peers);
|
||||
|
||||
expect(topology.getAssignment(roomId, "viewer-e")?.parentPeerId).toBe(
|
||||
"viewer-d",
|
||||
);
|
||||
expect(topology.getAssignment(roomId, "viewer-f")?.parentPeerId).toBeNull();
|
||||
expect(
|
||||
expectValidTree(
|
||||
topology,
|
||||
roomId,
|
||||
"host",
|
||||
peerIds.slice(1, -1),
|
||||
),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it("leaves a disconnected viewer subtree sticky during its grace window", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const roomId = "room";
|
||||
topology.setHost(roomId, "host", connected("host"));
|
||||
topology.addViewer(roomId, "viewer-a", connected("host", "viewer-a"));
|
||||
topology.addViewer(
|
||||
addRelayViewer(topology, roomId, "viewer-a", connected("host", "viewer-a"));
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-b",
|
||||
connected("host", "viewer-a", "viewer-b"),
|
||||
);
|
||||
topology.addViewer(
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-c",
|
||||
connected("host", "viewer-a", "viewer-b", "viewer-c"),
|
||||
);
|
||||
|
||||
topology.addViewer(
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-d",
|
||||
connected("host", "viewer-b", "viewer-c", "viewer-d"),
|
||||
@@ -125,23 +276,27 @@ describe("PeerRelayTopology", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const roomId = "room";
|
||||
topology.setHost(roomId, "host", connected("host"));
|
||||
topology.addViewer(roomId, "viewer-a", connected("host", "viewer-a"));
|
||||
topology.addViewer(
|
||||
addRelayViewer(topology, roomId, "viewer-a", connected("host", "viewer-a"));
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-b",
|
||||
connected("host", "viewer-a", "viewer-b"),
|
||||
);
|
||||
topology.addViewer(
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-c",
|
||||
connected("host", "viewer-a", "viewer-b", "viewer-c"),
|
||||
);
|
||||
topology.addViewer(
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-d",
|
||||
connected("host", "viewer-a", "viewer-b", "viewer-c", "viewer-d"),
|
||||
);
|
||||
topology.addViewer(
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-e",
|
||||
connected(
|
||||
@@ -180,13 +335,15 @@ describe("PeerRelayTopology", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const roomId = "room";
|
||||
topology.setHost(roomId, "host", connected("host"));
|
||||
topology.addViewer(roomId, "viewer-a", connected("host", "viewer-a"));
|
||||
topology.addViewer(
|
||||
addRelayViewer(topology, roomId, "viewer-a", connected("host", "viewer-a"));
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-b",
|
||||
connected("host", "viewer-a", "viewer-b"),
|
||||
);
|
||||
topology.addViewer(
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-c",
|
||||
connected("host", "viewer-a", "viewer-b", "viewer-c"),
|
||||
@@ -203,7 +360,8 @@ describe("PeerRelayTopology", () => {
|
||||
"viewer-b",
|
||||
]);
|
||||
|
||||
const reconnectChanges = topology.addViewer(
|
||||
const reconnectChanges = addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-c",
|
||||
connected("host", "viewer-b", "viewer-c"),
|
||||
@@ -223,14 +381,15 @@ describe("PeerRelayTopology", () => {
|
||||
const roomId = "room";
|
||||
topology.setHost(roomId, "host", connected("host"));
|
||||
for (const peerId of ["a", "b", "c", "d"]) {
|
||||
topology.addViewer(
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
peerId,
|
||||
connected("host", "a", "b", "c", "d"),
|
||||
);
|
||||
}
|
||||
|
||||
topology.addViewer(roomId, "e", connected("host", "c", "d", "e"));
|
||||
addRelayViewer(topology, roomId, "e", connected("host", "c", "d", "e"));
|
||||
expect(topology.getAssignment(roomId, "e")?.parentPeerId).toBeNull();
|
||||
|
||||
topology.removeViewer(roomId, "a", connected("host", "c", "d", "e"));
|
||||
@@ -242,7 +401,7 @@ describe("PeerRelayTopology", () => {
|
||||
|
||||
it("keeps pending viewers unassigned until a host arrives", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
topology.addViewer("room", "viewer-a", connected("viewer-a"));
|
||||
addRelayViewer(topology, "room", "viewer-a", connected("viewer-a"));
|
||||
expect(topology.getAssignment("room", "viewer-a")?.parentPeerId).toBeNull();
|
||||
|
||||
topology.setHost(
|
||||
@@ -258,17 +417,20 @@ describe("PeerRelayTopology", () => {
|
||||
it("updates only direct root parents when the host identity changes", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
topology.setHost("room", "host-a", connected("host-a"));
|
||||
topology.addViewer(
|
||||
addRelayViewer(
|
||||
topology,
|
||||
"room",
|
||||
"viewer-a",
|
||||
connected("host-a", "viewer-a"),
|
||||
);
|
||||
topology.addViewer(
|
||||
addRelayViewer(
|
||||
topology,
|
||||
"room",
|
||||
"viewer-b",
|
||||
connected("host-a", "viewer-a", "viewer-b"),
|
||||
);
|
||||
topology.addViewer(
|
||||
addRelayViewer(
|
||||
topology,
|
||||
"room",
|
||||
"viewer-c",
|
||||
connected("host-a", "viewer-a", "viewer-b", "viewer-c"),
|
||||
@@ -291,10 +453,123 @@ describe("PeerRelayTopology", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reassigns a failed edge deterministically without entering its subtree", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = ["host", "a", "b", "c", "d", "e"];
|
||||
topology.setHost("room", "host", connected(...peers));
|
||||
for (const peerId of peers.slice(1)) {
|
||||
addRelayViewer(topology, "room", peerId, connected(...peers));
|
||||
}
|
||||
|
||||
expect(topology.getAssignment("room", "e")?.parentPeerId).toBe("c");
|
||||
const changes = topology.reassignViewer(
|
||||
"room",
|
||||
"e",
|
||||
connected(...peers),
|
||||
new Set(["c"]),
|
||||
4,
|
||||
);
|
||||
|
||||
expect(changes?.map(({ peerId }) => peerId).sort()).toEqual([
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
]);
|
||||
expect(topology.getAssignment("room", "e")?.parentPeerId).toBe("d");
|
||||
expect(expectValidTree(topology, "room", "host", peers.slice(1))).toBe(3);
|
||||
});
|
||||
|
||||
it("does not mutate when every bounded reparent candidate is excluded", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
topology.setHost("room", "host", connected("host", "a", "b", "c"));
|
||||
for (const peerId of ["a", "b", "c"]) {
|
||||
addRelayViewer(
|
||||
topology,
|
||||
"room",
|
||||
peerId,
|
||||
connected("host", "a", "b", "c"),
|
||||
);
|
||||
}
|
||||
const before = topology.getAssignments("room");
|
||||
|
||||
expect(
|
||||
topology.reassignViewer(
|
||||
"room",
|
||||
"c",
|
||||
connected("host", "a", "b", "c"),
|
||||
new Set(["host", "a", "b"]),
|
||||
4,
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(topology.getAssignments("room")).toEqual(before);
|
||||
});
|
||||
|
||||
it("rejects a reparent whose deepest descendant would exceed max depth", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = ["host", "a", "b", "c", "d", "e", "f", "g", "h"];
|
||||
topology.setHost("room", "host", connected(...peers));
|
||||
for (const peerId of peers.slice(1)) {
|
||||
addRelayViewer(topology, "room", peerId, connected(...peers));
|
||||
}
|
||||
const before = topology.getAssignments("room");
|
||||
|
||||
expect(topology.getAssignment("room", "g")?.parentPeerId).toBe("e");
|
||||
expect(
|
||||
topology.reassignViewer(
|
||||
"room",
|
||||
"g",
|
||||
connected(...peers),
|
||||
new Set(["e"]),
|
||||
4,
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(topology.getAssignments("room")).toEqual(before);
|
||||
});
|
||||
|
||||
it("does not attach a pending subtree beyond the initial-admission depth bound", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const initialPeers = ["host", "a", "b", "c", "d", "e", "f", "g", "h"];
|
||||
topology.setHost("room", "host", connected(...initialPeers));
|
||||
for (const peerId of initialPeers.slice(1)) {
|
||||
addRelayViewer(topology, "room", peerId, connected(...initialPeers));
|
||||
}
|
||||
expect(topology.getAssignment("room", "e")).toMatchObject({
|
||||
parentPeerId: "c",
|
||||
childPeerIds: ["g"],
|
||||
});
|
||||
|
||||
topology.removeViewer(
|
||||
"room",
|
||||
"c",
|
||||
connected("host", "a", "b", "d", "f", "h"),
|
||||
);
|
||||
topology.removeViewer(
|
||||
"room",
|
||||
"h",
|
||||
connected("host", "a", "b", "d", "f"),
|
||||
);
|
||||
topology.addViewer(
|
||||
"room",
|
||||
"x",
|
||||
connected("host", "a", "b", "d", "f", "x"),
|
||||
);
|
||||
|
||||
topology.addViewer(
|
||||
"room",
|
||||
"e",
|
||||
connected("host", "a", "b", "d", "e", "f", "g", "x"),
|
||||
);
|
||||
|
||||
expect(topology.getAssignment("room", "e")?.parentPeerId).toBeNull();
|
||||
expect(topology.getAssignment("room", "g")?.parentPeerId).toBe("e");
|
||||
expect(topology.getAssignment("room", "f")?.childPeerIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops all assignments when a room is deleted", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
topology.setHost("room", "host-a", connected("host-a"));
|
||||
topology.addViewer(
|
||||
addRelayViewer(
|
||||
topology,
|
||||
"room",
|
||||
"viewer-a",
|
||||
connected("host-a", "viewer-a"),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MAX_MEDIA_ROUTE_REVISION,
|
||||
MAX_SFU_TOKEN_LENGTH,
|
||||
MAX_VIEWERS_PER_ROOM_LIMIT,
|
||||
clientMessageSchema,
|
||||
decodeClientMessage,
|
||||
participantRouteAssignmentSchema,
|
||||
serverMessageSchema,
|
||||
} from "../src/shared/protocol.js";
|
||||
|
||||
@@ -125,6 +128,22 @@ describe("client signaling protocol", () => {
|
||||
expect(clientMessageSchema.safeParse({ type: "stop-sharing" }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "stop-sharing",
|
||||
shareGeneration: "share_generation_12345678",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "authenticate",
|
||||
roomId,
|
||||
role: "host",
|
||||
token,
|
||||
clientId: "client_12345678",
|
||||
shareGeneration: "share_generation_12345678",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(clientMessageSchema.safeParse({ type: "close-room" }).success).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -154,6 +173,79 @@ describe("client signaling protocol", () => {
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only a binary browser relay capacity", () => {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "relay-capacity",
|
||||
downstreamEdges: 0,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "relay-capacity",
|
||||
downstreamEdges: 1,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "relay-capacity",
|
||||
downstreamEdges: 2,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "relay-capacity",
|
||||
downstreamEdges: 1,
|
||||
score: 100,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts bounded route acknowledgements, failures, and SFU refreshes", () => {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "route-ready",
|
||||
revision: 7,
|
||||
phase: "prepare",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "route-failed",
|
||||
revision: 7,
|
||||
phase: "active",
|
||||
connectionId: null,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "refresh-sfu",
|
||||
revision: MAX_MEDIA_ROUTE_REVISION,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "route-ready",
|
||||
revision: -1,
|
||||
phase: "prepare",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "route-failed",
|
||||
revision: 7,
|
||||
phase: "active",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "refresh-sfu",
|
||||
revision: 1.5,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("server signaling protocol", () => {
|
||||
@@ -209,6 +301,12 @@ describe("server signaling protocol", () => {
|
||||
parentPeerId: null,
|
||||
childPeerIds: ["viewer_12345678", "viewer_87654321"],
|
||||
},
|
||||
routeRevision: 0,
|
||||
routeAssignment: {
|
||||
upstream: { kind: "none" },
|
||||
childPeerIds: ["viewer_12345678", "viewer_87654321"],
|
||||
sfuPublicationGeneration: null,
|
||||
},
|
||||
qualityProfileId: "1080p60",
|
||||
};
|
||||
|
||||
@@ -223,6 +321,8 @@ describe("server signaling protocol", () => {
|
||||
serverMessageSchema.safeParse({
|
||||
...authenticatedMessage(8),
|
||||
mediaMode: "peer-assisted",
|
||||
routeRevision: 0,
|
||||
routeAssignment: peerAssisted.routeAssignment,
|
||||
qualityProfileId: "1080p60",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
@@ -231,6 +331,8 @@ describe("server signaling protocol", () => {
|
||||
...authenticatedMessage(8),
|
||||
mediaMode: "peer-assisted",
|
||||
mediaAssignment: peerAssisted.mediaAssignment,
|
||||
routeRevision: 0,
|
||||
routeAssignment: peerAssisted.routeAssignment,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
@@ -272,6 +374,91 @@ describe("server signaling protocol", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps hybrid route messages strict and separate from ordinary P2P auth", () => {
|
||||
const assignment = {
|
||||
upstream: { kind: "peer", peerId: "parent_12345678" },
|
||||
childPeerIds: ["child_12345678", "child_87654321"],
|
||||
sfuPublicationGeneration: null,
|
||||
};
|
||||
|
||||
expect(participantRouteAssignmentSchema.safeParse(assignment).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
type: "route-update",
|
||||
revision: 9,
|
||||
phase: "prepare",
|
||||
assignment,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
type: "sfu-config",
|
||||
revision: 9,
|
||||
url: "wss://sfu.example.com",
|
||||
token: "header.payload.signature",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
participantRouteAssignmentSchema.safeParse({
|
||||
...assignment,
|
||||
childPeerIds: [
|
||||
"child_12345678",
|
||||
"child_87654321",
|
||||
"child_overflow",
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
participantRouteAssignmentSchema.safeParse({
|
||||
...assignment,
|
||||
childPeerIds: ["child_12345678", "child_12345678"],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
type: "route-update",
|
||||
revision: 9,
|
||||
phase: "prepare",
|
||||
assignment,
|
||||
roomAssignments: [],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
type: "sfu-config",
|
||||
revision: 9,
|
||||
url: "wss://sfu.example.com",
|
||||
token: "x".repeat(MAX_SFU_TOKEN_LENGTH + 1),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
...authenticatedMessage(8),
|
||||
routeRevision: 9,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts every bounded hybrid upstream shape", () => {
|
||||
for (const upstream of [
|
||||
{ kind: "none" },
|
||||
{ kind: "peer", peerId: "parent_12345678" },
|
||||
{ kind: "sfu" },
|
||||
]) {
|
||||
expect(
|
||||
participantRouteAssignmentSchema.safeParse({
|
||||
upstream,
|
||||
childPeerIds: [],
|
||||
sfuPublicationGeneration:
|
||||
upstream.kind === "none" ? "generation_12345678" : null,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([0, 1.5, MAX_VIEWERS_PER_ROOM_LIMIT + 1])(
|
||||
"rejects viewer limit %s outside the protocol boundary",
|
||||
(maxViewers) => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
detectBrowserRelayCapacity,
|
||||
relayCapacityMessageForBrowser,
|
||||
} from "../src/client/media/relay-capability.ts";
|
||||
|
||||
const desktop = {
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140 Safari/537.36",
|
||||
userAgentDataMobile: false,
|
||||
maxTouchPoints: 0,
|
||||
};
|
||||
|
||||
describe("browser relay capability", () => {
|
||||
it("allows one downstream edge on a desktop browser", () => {
|
||||
expect(detectBrowserRelayCapacity(desktop)).toBe(1);
|
||||
expect(relayCapacityMessageForBrowser(true, desktop)).toEqual({
|
||||
type: "relay-capacity",
|
||||
downstreamEdges: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
userAgent: desktop.userAgent,
|
||||
userAgentDataMobile: true,
|
||||
maxTouchPoints: 0,
|
||||
},
|
||||
{
|
||||
userAgent: "Mozilla/5.0 (Linux; Android 16; Mobile)",
|
||||
userAgentDataMobile: false,
|
||||
maxTouchPoints: 5,
|
||||
},
|
||||
{
|
||||
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X)",
|
||||
maxTouchPoints: 5,
|
||||
},
|
||||
{
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)",
|
||||
userAgentDataMobile: false,
|
||||
maxTouchPoints: 5,
|
||||
},
|
||||
])("keeps mobile and touch iPadOS browsers as leaves", (environment) => {
|
||||
expect(detectBrowserRelayCapacity(environment)).toBe(0);
|
||||
});
|
||||
|
||||
it("does not create a relay message for ordinary P2P", () => {
|
||||
expect(relayCapacityMessageForBrowser(false)).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,165 @@ describe("server configuration", () => {
|
||||
expect(config.turnUrls).toEqual([]);
|
||||
expect(config.maxViewersPerRoom).toBe(8);
|
||||
expect(config.peerAssistedMedia).toBe(false);
|
||||
expect(config.livekitFallback).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enables LiveKit fallback only for a complete credential tuple", () => {
|
||||
const config = loadConfig({
|
||||
PEER_ASSISTED_MEDIA: "true",
|
||||
LIVEKIT_URL: " ws://livekit.test:7880 ",
|
||||
LIVEKIT_API_KEY: " test-key ",
|
||||
LIVEKIT_API_SECRET: ` ${"s".repeat(32)} `,
|
||||
});
|
||||
|
||||
expect(config.livekitFallback).toEqual({
|
||||
url: "ws://livekit.test:7880",
|
||||
apiKey: "test-key",
|
||||
apiSecret: "s".repeat(32),
|
||||
maxSfuRootsPerRoom: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["1", "2"])(
|
||||
"accepts an SFU root limit of %s when fallback is configured",
|
||||
(maxSfuRootsPerRoom) => {
|
||||
const config = loadConfig({
|
||||
PEER_ASSISTED_MEDIA: "true",
|
||||
LIVEKIT_URL: "wss://livekit.test",
|
||||
LIVEKIT_API_KEY: "test-key",
|
||||
LIVEKIT_API_SECRET: "s".repeat(32),
|
||||
MAX_SFU_ROOTS_PER_ROOM: maxSfuRootsPerRoom,
|
||||
});
|
||||
|
||||
expect(config.livekitFallback?.maxSfuRootsPerRoom).toBe(
|
||||
Number(maxSfuRootsPerRoom),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not activate fallback from the root limit alone", () => {
|
||||
expect(
|
||||
loadConfig({ MAX_SFU_ROOTS_PER_ROOM: "2" }).livekitFallback,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires peer-assisted media for LiveKit fallback", () => {
|
||||
expect(() =>
|
||||
loadConfig({
|
||||
PEER_ASSISTED_MEDIA: "false",
|
||||
LIVEKIT_URL: "wss://livekit.test",
|
||||
LIVEKIT_API_KEY: "test-key",
|
||||
LIVEKIT_API_SECRET: "s".repeat(32),
|
||||
}),
|
||||
).toThrow("LiveKit fallback requires PEER_ASSISTED_MEDIA=true");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ LIVEKIT_URL: "wss://livekit.test" },
|
||||
{ LIVEKIT_API_KEY: "test-key" },
|
||||
{ LIVEKIT_API_SECRET: "s".repeat(32) },
|
||||
{
|
||||
LIVEKIT_URL: "wss://livekit.test",
|
||||
LIVEKIT_API_KEY: "test-key",
|
||||
},
|
||||
])("rejects a partial LiveKit credential tuple", (partial) => {
|
||||
expect(() => loadConfig(partial)).toThrow(
|
||||
"LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must be configured together",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://livekit.test",
|
||||
"wss://user:pass@livekit.test",
|
||||
"wss://livekit.test/rtc",
|
||||
"wss://livekit.test?token=value",
|
||||
"wss://livekit.test#fragment",
|
||||
])("rejects an invalid LiveKit origin: %s", (url) => {
|
||||
expect(() =>
|
||||
loadConfig({
|
||||
LIVEKIT_URL: url,
|
||||
LIVEKIT_API_KEY: "test-key",
|
||||
LIVEKIT_API_SECRET: "s".repeat(32),
|
||||
}),
|
||||
).toThrow("LIVEKIT_URL");
|
||||
});
|
||||
|
||||
it("requires wss for LiveKit fallback in production", () => {
|
||||
expect(() =>
|
||||
loadConfig({
|
||||
NODE_ENV: "production",
|
||||
PUBLIC_BASE_URL: "https://share.test",
|
||||
LIVEKIT_URL: "ws://livekit.test:7880",
|
||||
LIVEKIT_API_KEY: "test-key",
|
||||
LIVEKIT_API_SECRET: "s".repeat(32),
|
||||
}),
|
||||
).toThrow("LIVEKIT_URL must use wss in production");
|
||||
});
|
||||
|
||||
it("rejects a short LiveKit API secret and invalid root limits", () => {
|
||||
const fallback = {
|
||||
LIVEKIT_URL: "wss://livekit.test",
|
||||
LIVEKIT_API_KEY: "test-key",
|
||||
LIVEKIT_API_SECRET: "s".repeat(32),
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
loadConfig({ ...fallback, LIVEKIT_API_SECRET: "too-short" }),
|
||||
).toThrow("LIVEKIT_API_SECRET must contain at least 32 bytes");
|
||||
for (const maxSfuRootsPerRoom of ["0", "3", "1.5"]) {
|
||||
expect(() =>
|
||||
loadConfig({
|
||||
...fallback,
|
||||
MAX_SFU_ROOTS_PER_ROOM: maxSfuRootsPerRoom,
|
||||
}),
|
||||
).toThrow("MAX_SFU_ROOTS_PER_ROOM");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps production STUN and TURN mandatory with LiveKit configured", () => {
|
||||
expect(() =>
|
||||
loadConfig({
|
||||
NODE_ENV: "production",
|
||||
PUBLIC_BASE_URL: "https://share.test",
|
||||
PEER_ASSISTED_MEDIA: "true",
|
||||
LIVEKIT_URL: "wss://livekit.test",
|
||||
LIVEKIT_API_KEY: "test-key",
|
||||
LIVEKIT_API_SECRET: "s".repeat(32),
|
||||
}),
|
||||
).toThrow("TURN is required in production");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
ACCESS_PASSWORD: "x".repeat(32),
|
||||
TURN_URLS: "turn:turn.test:3478",
|
||||
TURN_SHARED_SECRET: "x".repeat(32),
|
||||
},
|
||||
{
|
||||
ACCESS_PASSWORD: "x".repeat(32),
|
||||
PEER_ASSISTED_MEDIA: "true",
|
||||
LIVEKIT_URL: "wss://livekit.test",
|
||||
LIVEKIT_API_KEY: "test-key",
|
||||
LIVEKIT_API_SECRET: "x".repeat(32),
|
||||
},
|
||||
{
|
||||
TURN_URLS: "turn:turn.test:3478",
|
||||
TURN_SHARED_SECRET: "x".repeat(32),
|
||||
PEER_ASSISTED_MEDIA: "true",
|
||||
LIVEKIT_URL: "wss://livekit.test",
|
||||
LIVEKIT_API_KEY: "test-key",
|
||||
LIVEKIT_API_SECRET: "x".repeat(32),
|
||||
},
|
||||
{
|
||||
PEER_ASSISTED_MEDIA: "true",
|
||||
LIVEKIT_URL: "wss://livekit.test",
|
||||
LIVEKIT_API_KEY: "x".repeat(32),
|
||||
LIVEKIT_API_SECRET: "x".repeat(32),
|
||||
},
|
||||
])("rejects reused infrastructure secrets", (environment) => {
|
||||
expect(() => loadConfig(environment)).toThrow(
|
||||
"ACCESS_PASSWORD, TURN_SHARED_SECRET, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires an explicit boolean to enable peer-assisted media", () => {
|
||||
|
||||
@@ -52,7 +52,10 @@ function testConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
|
||||
|
||||
async function start(
|
||||
config = testConfig(),
|
||||
options: Pick<CreateServerOptions, "now" | "accessSessionTtlSeconds"> = {},
|
||||
options: Pick<
|
||||
CreateServerOptions,
|
||||
"now" | "accessSessionTtlSeconds" | "sfuTokenIssuer"
|
||||
> = {},
|
||||
): Promise<string> {
|
||||
runningServer = await createScreenerServer({
|
||||
...options,
|
||||
@@ -335,6 +338,28 @@ describe("room HTTP API", () => {
|
||||
});
|
||||
|
||||
describe("server HTTP listener and health", () => {
|
||||
it("starts with an injected optional SFU token issuer", async () => {
|
||||
const baseUrl = await start(
|
||||
testConfig({
|
||||
peerAssistedMedia: true,
|
||||
livekitFallback: {
|
||||
url: "ws://livekit.test:7880",
|
||||
apiKey: "test-key",
|
||||
apiSecret: "s".repeat(32),
|
||||
maxSfuRootsPerRoom: 2,
|
||||
},
|
||||
}),
|
||||
{
|
||||
sfuTokenIssuer: {
|
||||
issueToken: async () => "unused-test-token",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const response = await fetch(`${baseUrl}/healthz`);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("uses the configured listen host by default", async () => {
|
||||
runningServer = await createScreenerServer({
|
||||
config: testConfig(),
|
||||
|
||||
+1583
-1
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,460 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SfuPublisher } from "../src/client/sfu/publisher.ts";
|
||||
import { SfuSubscriber } from "../src/client/sfu/subscriber.ts";
|
||||
|
||||
type EventHandler = (...args: unknown[]) => void;
|
||||
|
||||
const livekit = vi.hoisted(() => {
|
||||
class FakeSender {
|
||||
parameters: RTCRtpSendParameters = {
|
||||
codecs: [],
|
||||
encodings: [{}],
|
||||
headerExtensions: [],
|
||||
rtcp: { cname: "fake", reducedSize: true },
|
||||
transactionId: "fake",
|
||||
};
|
||||
|
||||
readonly getParameters = vi.fn(() => this.parameters);
|
||||
readonly setParameters = vi.fn(
|
||||
async (parameters: RTCRtpSendParameters): Promise<void> => {
|
||||
this.parameters = parameters;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class FakeLocalTrack {
|
||||
currentTrack: MediaStreamTrack;
|
||||
readonly sender = new FakeSender();
|
||||
|
||||
constructor(track: MediaStreamTrack) {
|
||||
this.currentTrack = track;
|
||||
}
|
||||
|
||||
readonly replaceTrack = vi.fn(
|
||||
async (nextTrack: MediaStreamTrack): Promise<void> => {
|
||||
this.currentTrack = nextTrack;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class FakeLocalParticipant {
|
||||
readonly publications: Array<{
|
||||
track: FakeLocalTrack;
|
||||
videoTrack?: FakeLocalTrack;
|
||||
rawTrack: MediaStreamTrack;
|
||||
options: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
readonly publishTrack = vi.fn(
|
||||
async (
|
||||
rawTrack: MediaStreamTrack,
|
||||
options: Record<string, unknown>,
|
||||
) => {
|
||||
const localTrack = new FakeLocalTrack(rawTrack);
|
||||
const publication = {
|
||||
track: localTrack,
|
||||
videoTrack: rawTrack.kind === "video" ? localTrack : undefined,
|
||||
rawTrack,
|
||||
options,
|
||||
};
|
||||
this.publications.push(publication);
|
||||
return publication;
|
||||
},
|
||||
);
|
||||
|
||||
readonly unpublishTrack = vi.fn(
|
||||
async (_track: FakeLocalTrack, _stopOnUnpublish: boolean) => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
class FakeRemotePublication {
|
||||
readonly setSubscribed = vi.fn((_subscribed: boolean) => undefined);
|
||||
|
||||
constructor(
|
||||
readonly trackSid: string,
|
||||
readonly source: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
class FakeRemoteParticipant {
|
||||
readonly trackPublications = new Map<string, FakeRemotePublication>();
|
||||
|
||||
constructor(readonly identity: string) {}
|
||||
|
||||
add(publication: FakeRemotePublication): this {
|
||||
this.trackPublications.set(publication.trackSid, publication);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRoom {
|
||||
readonly localParticipant = new FakeLocalParticipant();
|
||||
readonly remoteParticipants = new Map<string, FakeRemoteParticipant>();
|
||||
readonly handlers = new Map<string, EventHandler[]>();
|
||||
readonly connect = vi.fn(
|
||||
async (
|
||||
_url: string,
|
||||
_token: string,
|
||||
_options: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
await state.connectGate;
|
||||
},
|
||||
);
|
||||
readonly disconnect = vi.fn(async (_stopTracks?: boolean) => undefined);
|
||||
|
||||
constructor() {
|
||||
state.rooms.push(this);
|
||||
}
|
||||
|
||||
on(event: string, handler: EventHandler): this {
|
||||
const handlers = this.handlers.get(event) ?? [];
|
||||
handlers.push(handler);
|
||||
this.handlers.set(event, handlers);
|
||||
return this;
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
for (const handler of this.handlers.get(event) ?? []) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const state: {
|
||||
rooms: FakeRoom[];
|
||||
connectGate: Promise<void> | null;
|
||||
} = {
|
||||
rooms: [],
|
||||
connectGate: null,
|
||||
};
|
||||
|
||||
return {
|
||||
FakeRemoteParticipant,
|
||||
FakeRemotePublication,
|
||||
FakeRoom,
|
||||
state,
|
||||
};
|
||||
});
|
||||
|
||||
const RoomEvent = {
|
||||
Disconnected: "disconnected",
|
||||
ParticipantDisconnected: "participant-disconnected",
|
||||
TrackPublished: "track-published",
|
||||
TrackSubscribed: "track-subscribed",
|
||||
TrackUnpublished: "track-unpublished",
|
||||
TrackUnsubscribed: "track-unsubscribed",
|
||||
} as const;
|
||||
|
||||
const Track = {
|
||||
Source: {
|
||||
Camera: "camera",
|
||||
ScreenShare: "screen-share",
|
||||
ScreenShareAudio: "screen-share-audio",
|
||||
},
|
||||
} as const;
|
||||
|
||||
vi.mock("livekit-client", () => ({
|
||||
Room: livekit.FakeRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
}));
|
||||
|
||||
class FakeMediaStream {
|
||||
constructor(private readonly tracks: MediaStreamTrack[] = []) {}
|
||||
|
||||
getTracks(): MediaStreamTrack[] {
|
||||
return [...this.tracks];
|
||||
}
|
||||
|
||||
getVideoTracks(): MediaStreamTrack[] {
|
||||
return this.tracks.filter((item) => item.kind === "video");
|
||||
}
|
||||
|
||||
getAudioTracks(): MediaStreamTrack[] {
|
||||
return this.tracks.filter((item) => item.kind === "audio");
|
||||
}
|
||||
}
|
||||
|
||||
const connection = {
|
||||
url: "wss://sfu.example.test",
|
||||
token: "short-lived-token",
|
||||
};
|
||||
|
||||
const qualityProfile = {
|
||||
label: "1080p 60",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: 60,
|
||||
maxBitrate: 8_000_000,
|
||||
} as const;
|
||||
|
||||
function track(kind: "video" | "audio", id: string): MediaStreamTrack {
|
||||
return { id, kind } as MediaStreamTrack;
|
||||
}
|
||||
|
||||
function stream(...tracks: MediaStreamTrack[]): MediaStream {
|
||||
return new FakeMediaStream(tracks) as unknown as MediaStream;
|
||||
}
|
||||
|
||||
function deferred(): {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
} {
|
||||
let resolve = (): void => undefined;
|
||||
const promise = new Promise<void>((next) => {
|
||||
resolve = next;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
livekit.state.rooms.length = 0;
|
||||
livekit.state.connectGate = null;
|
||||
vi.stubGlobal("MediaStream", FakeMediaStream);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("SfuPublisher", () => {
|
||||
it("prepares a connection without publishing media", async () => {
|
||||
const publisher = new SfuPublisher();
|
||||
|
||||
await expect(publisher.connect(connection)).resolves.toBe(true);
|
||||
|
||||
const room = livekit.state.rooms[0];
|
||||
expect(room.connect).toHaveBeenCalledWith(connection.url, connection.token, {
|
||||
autoSubscribe: false,
|
||||
});
|
||||
expect(room.localParticipant.publishTrack).not.toHaveBeenCalled();
|
||||
|
||||
await publisher.disconnect();
|
||||
await publisher.disconnect();
|
||||
expect(room.disconnect).toHaveBeenCalledOnce();
|
||||
expect(room.disconnect).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("publishes one screen video and optional audio only while active", async () => {
|
||||
const publisher = new SfuPublisher();
|
||||
const video = track("video", "video-1");
|
||||
const audio = track("audio", "audio-1");
|
||||
await publisher.connect(connection);
|
||||
|
||||
await expect(
|
||||
publisher.activate(stream(video, audio), qualityProfile),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const room = livekit.state.rooms[0];
|
||||
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(1, video, {
|
||||
source: Track.Source.ScreenShare,
|
||||
simulcast: false,
|
||||
screenShareEncoding: {
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 60,
|
||||
},
|
||||
degradationPreference: "balanced",
|
||||
});
|
||||
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(2, audio, {
|
||||
source: Track.Source.ScreenShareAudio,
|
||||
dtx: false,
|
||||
});
|
||||
|
||||
await expect(publisher.deactivate()).resolves.toBe(true);
|
||||
expect(room.localParticipant.unpublishTrack).toHaveBeenCalledTimes(2);
|
||||
expect(room.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates the active sender profile without republishing", async () => {
|
||||
const publisher = new SfuPublisher();
|
||||
await publisher.connect(connection);
|
||||
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
|
||||
const room = livekit.state.rooms[0];
|
||||
const videoPublication = room.localParticipant.publications[0];
|
||||
|
||||
await expect(
|
||||
publisher.updateProfile({
|
||||
label: "720p 30",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frameRate: 30,
|
||||
maxBitrate: 3_000_000,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(videoPublication.track.sender.setParameters).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
degradationPreference: "balanced",
|
||||
encodings: [
|
||||
expect.objectContaining({
|
||||
maxBitrate: 3_000_000,
|
||||
maxFramerate: 30,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(room.localParticipant.publishTrack).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("restores the previous video after a partially applied replacement fails", async () => {
|
||||
const previousVideo = track("video", "video-1");
|
||||
const nextVideo = track("video", "video-2");
|
||||
const publisher = new SfuPublisher();
|
||||
await publisher.connect(connection);
|
||||
await publisher.activate(stream(previousVideo), qualityProfile);
|
||||
const localTrack = livekit.state.rooms[0].localParticipant.publications[0].track;
|
||||
localTrack.replaceTrack.mockImplementationOnce(async (replacement) => {
|
||||
localTrack.currentTrack = replacement;
|
||||
throw new Error("sender swap failed after applying");
|
||||
});
|
||||
|
||||
await expect(publisher.replaceStream(stream(nextVideo))).resolves.toBe(false);
|
||||
|
||||
expect(localTrack.replaceTrack).toHaveBeenNthCalledWith(1, nextVideo);
|
||||
expect(localTrack.replaceTrack).toHaveBeenNthCalledWith(2, previousVideo);
|
||||
expect(localTrack.currentTrack).toBe(previousVideo);
|
||||
});
|
||||
|
||||
it("disconnects fail-closed when replacement rollback also fails", async () => {
|
||||
const disconnected = vi.fn();
|
||||
const previousVideo = track("video", "video-1");
|
||||
const publisher = new SfuPublisher({ onDisconnected: disconnected });
|
||||
await publisher.connect(connection);
|
||||
await publisher.activate(stream(previousVideo), qualityProfile);
|
||||
const room = livekit.state.rooms[0];
|
||||
const localTrack = room.localParticipant.publications[0].track;
|
||||
localTrack.replaceTrack
|
||||
.mockRejectedValueOnce(new Error("replace failed"))
|
||||
.mockRejectedValueOnce(new Error("rollback failed"));
|
||||
|
||||
await expect(
|
||||
publisher.replaceStream(stream(track("video", "video-2"))),
|
||||
).rejects.toThrow("rollback failed");
|
||||
expect(room.disconnect).toHaveBeenCalledWith(false);
|
||||
expect(disconnected).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not resurrect a stale connection after disconnect", async () => {
|
||||
const gate = deferred();
|
||||
livekit.state.connectGate = gate.promise;
|
||||
const publisher = new SfuPublisher();
|
||||
const connecting = publisher.connect(connection);
|
||||
await vi.waitFor(() => expect(livekit.state.rooms).toHaveLength(1));
|
||||
const room = livekit.state.rooms[0];
|
||||
|
||||
await publisher.disconnect();
|
||||
gate.resolve();
|
||||
|
||||
await expect(connecting).resolves.toBe(false);
|
||||
expect(room.localParticipant.publishTrack).not.toHaveBeenCalled();
|
||||
expect(room.disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SfuSubscriber", () => {
|
||||
it("stays inactive while prepared and selectively subscribes after activation", async () => {
|
||||
const gate = deferred();
|
||||
livekit.state.connectGate = gate.promise;
|
||||
const streams: Array<MediaStream | null> = [];
|
||||
const subscriber = new SfuSubscriber({
|
||||
onStream: (nextStream) => streams.push(nextStream),
|
||||
});
|
||||
const connecting = subscriber.connect(connection);
|
||||
await vi.waitFor(() => expect(livekit.state.rooms).toHaveLength(1));
|
||||
const room = livekit.state.rooms[0];
|
||||
const host = new livekit.FakeRemoteParticipant("host");
|
||||
const hostVideo = new livekit.FakeRemotePublication(
|
||||
"host-video",
|
||||
Track.Source.ScreenShare,
|
||||
);
|
||||
const hostAudio = new livekit.FakeRemotePublication(
|
||||
"host-audio",
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
const hostCamera = new livekit.FakeRemotePublication(
|
||||
"host-camera",
|
||||
Track.Source.Camera,
|
||||
);
|
||||
host.add(hostVideo).add(hostAudio).add(hostCamera);
|
||||
room.remoteParticipants.set("host", host);
|
||||
const viewer = new livekit.FakeRemoteParticipant("viewer:other");
|
||||
const viewerScreen = new livekit.FakeRemotePublication(
|
||||
"viewer-screen",
|
||||
Track.Source.ScreenShare,
|
||||
);
|
||||
viewer.add(viewerScreen);
|
||||
room.remoteParticipants.set(viewer.identity, viewer);
|
||||
|
||||
gate.resolve();
|
||||
await expect(connecting).resolves.toBe(true);
|
||||
expect(room.connect).toHaveBeenCalledWith(connection.url, connection.token, {
|
||||
autoSubscribe: false,
|
||||
});
|
||||
expect(hostVideo.setSubscribed).not.toHaveBeenCalled();
|
||||
expect(streams).toEqual([]);
|
||||
|
||||
expect(subscriber.activate()).toBe(true);
|
||||
expect(hostVideo.setSubscribed).toHaveBeenCalledWith(true);
|
||||
expect(hostAudio.setSubscribed).toHaveBeenCalledWith(true);
|
||||
expect(hostCamera.setSubscribed).not.toHaveBeenCalled();
|
||||
expect(viewerScreen.setSubscribed).not.toHaveBeenCalled();
|
||||
|
||||
const audio = track("audio", "audio-1");
|
||||
room.emit(
|
||||
RoomEvent.TrackSubscribed,
|
||||
{ mediaStreamTrack: audio },
|
||||
hostAudio,
|
||||
host,
|
||||
);
|
||||
expect(streams).toEqual([]);
|
||||
|
||||
const video = track("video", "video-1");
|
||||
room.emit(
|
||||
RoomEvent.TrackSubscribed,
|
||||
{ mediaStreamTrack: video },
|
||||
hostVideo,
|
||||
host,
|
||||
);
|
||||
expect(streams.at(-1)?.getTracks()).toEqual([video, audio]);
|
||||
|
||||
expect(subscriber.deactivate()).toBe(true);
|
||||
expect(hostVideo.setSubscribed).toHaveBeenLastCalledWith(false);
|
||||
expect(hostAudio.setSubscribed).toHaveBeenLastCalledWith(false);
|
||||
expect(streams.at(-1)).toBeNull();
|
||||
expect(room.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifies the controller after a terminal room disconnect", async () => {
|
||||
const disconnected = vi.fn();
|
||||
const subscriber = new SfuSubscriber({
|
||||
onStream: vi.fn(),
|
||||
onDisconnected: disconnected,
|
||||
});
|
||||
await subscriber.connect(connection);
|
||||
const room = livekit.state.rooms[0];
|
||||
|
||||
room.emit(RoomEvent.Disconnected);
|
||||
room.emit(RoomEvent.Disconnected);
|
||||
|
||||
expect(disconnected).toHaveBeenCalledOnce();
|
||||
expect(subscriber.deactivate()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not activate a stale connection after disconnect", async () => {
|
||||
const gate = deferred();
|
||||
livekit.state.connectGate = gate.promise;
|
||||
const subscriber = new SfuSubscriber({ onStream: vi.fn() });
|
||||
const connecting = subscriber.connect(connection);
|
||||
await vi.waitFor(() => expect(livekit.state.rooms).toHaveLength(1));
|
||||
const room = livekit.state.rooms[0];
|
||||
|
||||
await subscriber.disconnect();
|
||||
await subscriber.disconnect();
|
||||
gate.resolve();
|
||||
|
||||
await expect(connecting).resolves.toBe(false);
|
||||
expect(room.disconnect).toHaveBeenCalledOnce();
|
||||
expect(() => subscriber.activate()).toThrow("expected prepared");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ViewerMessageAuthority } from "../src/client/media/viewer-message-authority.ts";
|
||||
import type { ServerMessage } from "../src/shared/protocol.ts";
|
||||
|
||||
function message(type: ServerMessage["type"]): ServerMessage {
|
||||
return { type } as ServerMessage;
|
||||
}
|
||||
|
||||
describe("ViewerMessageAuthority", () => {
|
||||
it("invalidates an awaited authenticated continuation on newer authority", () => {
|
||||
const authority = new ViewerMessageAuthority();
|
||||
const authenticated = authority.tokenFor(message("authenticated"));
|
||||
|
||||
expect(authority.tokenFor(message("route-update"))).toBe(authenticated);
|
||||
expect(authority.owns(authenticated)).toBe(true);
|
||||
|
||||
authority.tokenFor(message("sharing-stopped"));
|
||||
expect(authority.owns(authenticated)).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidates pending work on a newer auth, ICE snapshot, or termination", () => {
|
||||
const authority = new ViewerMessageAuthority();
|
||||
const first = authority.tokenFor(message("authenticated"));
|
||||
authority.tokenFor(message("ice-config"));
|
||||
expect(authority.owns(first)).toBe(false);
|
||||
|
||||
const second = authority.tokenFor(message("authenticated"));
|
||||
authority.invalidate();
|
||||
expect(authority.owns(second)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -104,6 +104,8 @@ class FakePeerConnection extends EventTarget {
|
||||
|
||||
const intervalCallbacks = new Map<number, () => void>();
|
||||
let nextIntervalId = 1;
|
||||
const timeoutCallbacks = new Map<number, () => void>();
|
||||
let nextTimeoutId = 1;
|
||||
|
||||
function offer(connectionId: string): SignalPayload {
|
||||
return {
|
||||
@@ -154,6 +156,8 @@ beforeEach(() => {
|
||||
FakePeerConnection.plans.length = 0;
|
||||
intervalCallbacks.clear();
|
||||
nextIntervalId = 1;
|
||||
timeoutCallbacks.clear();
|
||||
nextTimeoutId = 1;
|
||||
vi.stubGlobal("MediaStream", FakeMediaStream);
|
||||
vi.stubGlobal("RTCPeerConnection", FakePeerConnection);
|
||||
vi.stubGlobal("window", {
|
||||
@@ -163,8 +167,12 @@ beforeEach(() => {
|
||||
return id;
|
||||
},
|
||||
clearInterval: (id: number) => intervalCallbacks.delete(id),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setTimeout: (callback: () => void) => {
|
||||
const id = nextTimeoutId++;
|
||||
timeoutCallbacks.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id: number) => timeoutCallbacks.delete(id),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,6 +181,90 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ViewerPeer connection generations", () => {
|
||||
it("reports one exhausted edge after bounded restart and rebuild attempts", async () => {
|
||||
const restartRequests: Array<{
|
||||
peerId: string;
|
||||
connectionId: string;
|
||||
rebuild: boolean;
|
||||
}> = [];
|
||||
const exhausted: Array<{ peerId: string; connectionId: string }> = [];
|
||||
const peer = new ViewerPeer(
|
||||
{ iceServers: [], expiresAt: null, relayAvailable: false },
|
||||
{
|
||||
sendSignal: () => true,
|
||||
sendRestartRequest: (peerId, connectionId, rebuild) => {
|
||||
restartRequests.push({ peerId, connectionId, rebuild });
|
||||
return true;
|
||||
},
|
||||
onStream: () => undefined,
|
||||
onUpdate: () => undefined,
|
||||
onRecoveryExhausted: (peerId, connectionId) => {
|
||||
exhausted.push({ peerId, connectionId });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
await peer.acceptSignal("relay-parent", offer("failed-connection"));
|
||||
const connection = FakePeerConnection.instances[0]!;
|
||||
connection.connectionState = "failed";
|
||||
connection.dispatchEvent(new Event("connectionstatechange"));
|
||||
|
||||
expect(restartRequests).toEqual([
|
||||
{
|
||||
peerId: "relay-parent",
|
||||
connectionId: "failed-connection",
|
||||
rebuild: false,
|
||||
},
|
||||
]);
|
||||
timeoutCallbacks.get(1)!();
|
||||
expect(restartRequests.at(-1)).toEqual({
|
||||
peerId: "relay-parent",
|
||||
connectionId: "failed-connection",
|
||||
rebuild: true,
|
||||
});
|
||||
timeoutCallbacks.get(2)!();
|
||||
expect(exhausted).toEqual([
|
||||
{ peerId: "relay-parent", connectionId: "failed-connection" },
|
||||
]);
|
||||
|
||||
connection.dispatchEvent(new Event("connectionstatechange"));
|
||||
expect(restartRequests).toHaveLength(2);
|
||||
expect(exhausted).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("retries delivery of an exhausted edge after signaling reconnects", async () => {
|
||||
let reportAvailable = false;
|
||||
const exhausted: string[] = [];
|
||||
const peer = new ViewerPeer(
|
||||
{ iceServers: [], expiresAt: null, relayAvailable: false },
|
||||
{
|
||||
sendSignal: () => true,
|
||||
sendRestartRequest: () => true,
|
||||
onStream: () => undefined,
|
||||
onUpdate: () => undefined,
|
||||
onRecoveryExhausted: (_peerId, connectionId) => {
|
||||
exhausted.push(connectionId);
|
||||
return reportAvailable;
|
||||
},
|
||||
},
|
||||
);
|
||||
await peer.acceptSignal("relay-parent", offer("failed-connection"));
|
||||
const connection = FakePeerConnection.instances[0]!;
|
||||
connection.connectionState = "failed";
|
||||
connection.dispatchEvent(new Event("connectionstatechange"));
|
||||
|
||||
timeoutCallbacks.get(1)!();
|
||||
timeoutCallbacks.get(2)!();
|
||||
expect(exhausted).toEqual(["failed-connection"]);
|
||||
|
||||
reportAvailable = true;
|
||||
timeoutCallbacks.get(3)!();
|
||||
expect(exhausted).toEqual(["failed-connection", "failed-connection"]);
|
||||
|
||||
connection.dispatchEvent(new Event("connectionstatechange"));
|
||||
expect(exhausted).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("requests one bounded rebuild when answer negotiation fails", async () => {
|
||||
FakePeerConnection.plans.push(
|
||||
{ answerError: new Error("first answer failed") },
|
||||
@@ -208,6 +300,36 @@ describe("ViewerPeer connection generations", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("requests a rebuild when the completed answer cannot be signaled", async () => {
|
||||
const restartRequests: Array<{
|
||||
peerId: string;
|
||||
connectionId: string;
|
||||
rebuild: boolean;
|
||||
}> = [];
|
||||
const peer = new ViewerPeer(
|
||||
{ iceServers: [], expiresAt: null, relayAvailable: false },
|
||||
{
|
||||
sendSignal: () => false,
|
||||
sendRestartRequest: (peerId, connectionId, rebuild) => {
|
||||
restartRequests.push({ peerId, connectionId, rebuild });
|
||||
return true;
|
||||
},
|
||||
onStream: () => undefined,
|
||||
onUpdate: () => undefined,
|
||||
},
|
||||
);
|
||||
|
||||
await peer.acceptSignal("relay-parent", offer("unsent-answer"));
|
||||
|
||||
expect(restartRequests).toEqual([
|
||||
{
|
||||
peerId: "relay-parent",
|
||||
connectionId: "unsent-answer",
|
||||
rebuild: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("targets answers and recovery requests at the current parent", async () => {
|
||||
const signals: SignalPayload[] = [];
|
||||
const signalPeers: string[] = [];
|
||||
|
||||
Reference in new issue
Block a user