From 96b2d4f1e63ca54e32ef7339ea08bf82548ec526 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:38:13 +0800 Subject: [PATCH] feat(media): add bounded automatic hybrid routing --- package-lock.json | 157 ++ package.json | 2 + public/third-party-licenses.txt | 212 +++ src/client/media/host-sfu-route.ts | 520 ++++++ src/client/media/relay-capability.ts | 51 + src/client/media/route-transition.ts | 164 ++ src/client/media/viewer-message-authority.ts | 29 + src/client/media/viewer-sfu-route.ts | 495 ++++++ src/client/pages/HostPage.tsx | 125 +- src/client/pages/ViewerPage.tsx | 182 +- src/client/sfu/publisher.ts | 481 ++++++ src/client/sfu/subscriber.ts | 355 ++++ src/client/webrtc/viewer-peer.ts | 113 +- src/server/app.ts | 18 + src/server/config.ts | 86 + src/server/hybrid-media-router.ts | 1605 ++++++++++++++++++ src/server/livekit-token.ts | 129 ++ src/server/media-route-controller.ts | 425 +++++ src/server/peer-relay-topology.ts | 208 ++- src/server/signaling.ts | 276 +-- src/shared/protocol.ts | 98 +- tests/livekit-token.test.ts | 141 ++ tests/media-route-controller.test.ts | 335 ++++ tests/peer-relay-topology.test.ts | 319 +++- tests/protocol.test.ts | 187 ++ tests/relay-capability.test.ts | 51 + tests/route-transition.test.ts | 1029 +++++++++++ tests/server-config.test.ts | 159 ++ tests/server-http.test.ts | 27 +- tests/server-signal.test.ts | 1584 ++++++++++++++++- tests/sfu-client.test.ts | 460 +++++ tests/viewer-message-authority.test.ts | 32 + tests/viewer-peer-generation.test.ts | 126 +- 33 files changed, 9974 insertions(+), 207 deletions(-) create mode 100644 public/third-party-licenses.txt create mode 100644 src/client/media/host-sfu-route.ts create mode 100644 src/client/media/relay-capability.ts create mode 100644 src/client/media/route-transition.ts create mode 100644 src/client/media/viewer-message-authority.ts create mode 100644 src/client/media/viewer-sfu-route.ts create mode 100644 src/client/sfu/publisher.ts create mode 100644 src/client/sfu/subscriber.ts create mode 100644 src/server/hybrid-media-router.ts create mode 100644 src/server/livekit-token.ts create mode 100644 src/server/media-route-controller.ts create mode 100644 tests/livekit-token.test.ts create mode 100644 tests/media-route-controller.test.ts create mode 100644 tests/relay-capability.test.ts create mode 100644 tests/route-transition.test.ts create mode 100644 tests/sfu-client.test.ts create mode 100644 tests/viewer-message-authority.test.ts diff --git a/package-lock.json b/package-lock.json index 73f295b..e22da31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5c1d674..0c9586d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/third-party-licenses.txt b/public/third-party-licenses.txt new file mode 100644 index 0000000..ffba06d --- /dev/null +++ b/public/third-party-licenses.txt @@ -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. diff --git a/src/client/media/host-sfu-route.ts b/src/client/media/host-sfu-route.ts new file mode 100644 index 0000000..106d7f7 --- /dev/null +++ b/src/client/media/host-sfu-route.ts @@ -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; + activate(stream: MediaStream, profile: QualityProfile): Promise; + deactivate(): Promise; + replaceStream(stream: MediaStream): Promise; + updateProfile(profile: QualityProfile): Promise; + disconnect(): Promise; +} + +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 = 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 { + 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, + ): Promise { + 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 { + return this.active?.active + ? this.active.publisher.updateProfile(profile).catch(() => false) + : Promise.resolve(true); + } + + replaceStream(stream: MediaStream): Promise { + return this.active + ? this.active.publisher.replaceStream(stream).catch(() => false) + : Promise.resolve(true); + } + + async failActivePublisher(): Promise { + await this.queueTransition(async () => { + const slot = this.active; + if (!slot) { + return; + } + this.active = null; + await disconnectPublisher(slot.publisher); + this.handleFailure(slot); + }); + } + + async disconnect(): Promise { + 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 { + 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): Promise { + 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 { + 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 { + 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 { + await publisher.disconnect().catch(() => undefined); +} diff --git a/src/client/media/relay-capability.ts b/src/client/media/relay-capability.ts new file mode 100644 index 0000000..7140c09 --- /dev/null +++ b/src/client/media/relay-capability.ts @@ -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, + }; +} diff --git a/src/client/media/route-transition.ts b/src/client/media/route-transition.ts new file mode 100644 index 0000000..fb47868 --- /dev/null +++ b/src/client/media/route-transition.ts @@ -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, + }; +} diff --git a/src/client/media/viewer-message-authority.ts b/src/client/media/viewer-message-authority.ts new file mode 100644 index 0000000..3d990c3 --- /dev/null +++ b/src/client/media/viewer-message-authority.ts @@ -0,0 +1,29 @@ +import type { ServerMessage } from "../../shared/protocol"; + +const AUTHORITY_MESSAGE_TYPES = new Set([ + "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; + } +} diff --git a/src/client/media/viewer-sfu-route.ts b/src/client/media/viewer-sfu-route.ts new file mode 100644 index 0000000..b5e21c9 --- /dev/null +++ b/src/client/media/viewer-sfu-route.ts @@ -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; + activate(): boolean; + deactivate(): boolean; + disconnect(): Promise; +} + +interface ViewerSubscriberSlot { + revision: number; + subscriber: ViewerSubscriberTransport; + connected: boolean; + activated: boolean; + failed: boolean; + activationToken: RouteOperationToken | null; +} + +interface ViewerSfuRouteEvents { + activatePeer: ( + assignment: ParticipantRouteAssignment, + ) => void | Promise; + 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 = 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 { + 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, + ): Promise { + 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 { + 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 { + 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): Promise { + const next = this.transitionTail.then(work, work); + this.transitionTail = next.catch(() => undefined); + return this.transitionTail; + } + + private async activateSfu( + token: RouteOperationToken, + acknowledge: boolean, + ): Promise { + 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 { + 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 { + await subscriber.disconnect().catch(() => undefined); +} diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index bcae8a9..38d268e 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -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( DEFAULT_QUALITY_PROFILE_ID, ); + const shareGenerationRef = useRef(null); const [phase, setPhase] = useState("idle"); const [signalStatus, setSignalStatus] = useState("offline"); @@ -143,6 +147,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const qualityIdRef = useRef(DEFAULT_QUALITY_PROFILE_ID); const picturePausedRef = useRef(false); const retiringStreamRef = useRef(null); + const hostSfuRouteRef = useRef(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) { diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index aaa903d..c25be61 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -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 + > = []; + 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 { + 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 { + async function handleMessage( + message: ServerMessage, + authorityToken: number, + ): Promise { 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(); diff --git a/src/client/sfu/publisher.ts b/src/client/sfu/publisher.ts new file mode 100644 index 0000000..43cabd7 --- /dev/null +++ b/src/client/sfu/publisher.ts @@ -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>(); + +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 = Promise.resolve(); + private terminalNotified = false; + + constructor(private readonly events: PublisherEvents = {}) {} + + async connect(config: SfuConnectionConfig): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const room = this.invalidate(); + if (room) { + await safeDisconnect(room); + } + } + + private enqueue(operation: () => Promise): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/client/sfu/subscriber.ts b/src/client/sfu/subscriber.ts new file mode 100644 index 0000000..06066da --- /dev/null +++ b/src/client/sfu/subscriber.ts @@ -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>(); + +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(); + private state: SubscriberState = "idle"; + private generation = 0; + private terminalNotified = false; + + constructor(private readonly events: SubscriberEvents) {} + + async connect(config: SfuConnectionConfig): Promise { + 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 { + 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 { + 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 { + const existing = roomDisconnects.get(room); + if (existing) { + await existing; + return; + } + const disconnecting = room.disconnect(false).catch(() => undefined); + roomDisconnects.set(room, disconnecting); + await disconnecting; +} diff --git a/src/client/webrtc/viewer-peer.ts b/src/client/webrtc/viewer-peer.ts index b24a34d..54b2d81 100644 --- a/src/client/webrtc/viewer-peer.ts +++ b/src/client/webrtc/viewer-peer.ts @@ -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) { diff --git a/src/server/app.ts b/src/server/app.ts index fc589c8..d5b87f9 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -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 { 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) => diff --git a/src/server/config.ts b/src/server/config.ts index 48ba503..4ea1e93 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -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, diff --git a/src/server/hybrid-media-router.ts b/src/server/hybrid-media-router.ts new file mode 100644 index 0000000..e6fe652 --- /dev/null +++ b/src/server/hybrid-media-router.ts @@ -0,0 +1,1605 @@ +import { randomBytes } from "node:crypto"; + +import { + MAX_MEDIA_ROUTE_REVISION, + type ClientMessage, + type MediaAssignment, + type ParticipantRouteAssignment, + type RelayDownstreamEdges, + type Role, + type ServerMessage, +} from "../shared/protocol.js"; +import type { SfuTokenIssuer } from "./livekit-token.js"; +import { + MediaRouteController, + type RoomMediaRoute, +} from "./media-route-controller.js"; +import { + MAX_PEER_RELAY_DEPTH, + PeerRelayTopology, + type MediaAssignmentChange, +} from "./peer-relay-topology.js"; +import type { RoomStore } from "./room-store.js"; + +type ErrorCode = Extract["code"]; + +const DEFAULT_SFU_PREPARE_TIMEOUT_MS = 5_000; +interface PendingRoutePreparation { + revision: number; + intentPeerId: string; + expectedSessionIds: ReadonlyMap; + grantsIssued: boolean; + timer: NodeJS.Timeout; +} + +interface ViewerRouteIntent { + failedParentPeerId: string | null; + sessionId: string; + sfuAttempts: number; + unavailableReported: boolean; +} + +type SfuPrepareResult = "started" | "busy" | "unavailable"; + +export interface SfuFallbackOptions { + url: string; + tokenIssuer: SfuTokenIssuer; + maxRoots: number; + prepareTimeoutMs?: number; +} + +export interface HybridMediaRouterOptions { + roomStore: RoomStore; + sfuFallback?: SfuFallbackOptions; + sendToSession: (sessionId: string, message: ServerMessage) => void; + getConnectionId: (roomId: string, viewerPeerId: string) => string | undefined; + deleteConnectionId: (roomId: string, viewerPeerId: string) => void; +} + +export interface HybridAuthenticationState { + routeRevision: number; + routeAssignment: ParticipantRouteAssignment; + mediaAssignment: MediaAssignment; + assignmentChanges: readonly MediaAssignmentChange[]; + activeRoute?: RoomMediaRoute; +} + +export interface AuthenticatedRouteParticipant { + roomId: string; + role: Role; + peerId: string; + sessionId: string; +} + +export class HybridMediaRouter { + private readonly peerRelayTopology = new PeerRelayTopology(); + private readonly mediaRouteControllers = new Map< + string, + MediaRouteController + >(); + private readonly pendingRoutePreparations = new Map< + string, + PendingRoutePreparation + >(); + private readonly failedParentPeerIdsByViewer = new Map< + string, + Set + >(); + private readonly viewerRouteIntentsByRoom = new Map< + string, + Map + >(); + private readonly consumedSfuRefreshesByRoom = new Map>(); + private readonly relayCapacitySessionsByRoom = new Map< + string, + Map + >(); + private readonly sfuDisabledRoomIds = new Set(); + + constructor(private readonly options: HybridMediaRouterOptions) { + const fallback = options.sfuFallback; + if (!fallback) { + return; + } + if ( + !Number.isSafeInteger(fallback.maxRoots) || + fallback.maxRoots < 1 || + fallback.maxRoots > Math.min(2, options.roomStore.maxViewersPerRoom) || + (fallback.prepareTimeoutMs !== undefined && + (!Number.isSafeInteger(fallback.prepareTimeoutMs) || + fallback.prepareTimeoutMs <= 0)) + ) { + throw new Error("SFU fallback limits are invalid"); + } + new URL(fallback.url); + } + + close(): void { + for (const pending of this.pendingRoutePreparations.values()) { + clearTimeout(pending.timer); + } + this.pendingRoutePreparations.clear(); + this.mediaRouteControllers.clear(); + this.failedParentPeerIdsByViewer.clear(); + this.viewerRouteIntentsByRoom.clear(); + this.consumedSfuRefreshesByRoom.clear(); + this.relayCapacitySessionsByRoom.clear(); + this.sfuDisabledRoomIds.clear(); + } + + connectParticipant(input: AuthenticatedRouteParticipant): HybridAuthenticationState { + this.clearSfuRefreshesForPeer(input.roomId, input.peerId); + if (input.role === "viewer") { + this.failedParentPeerIdsByViewer.delete( + viewerConnectionKey(input.roomId, input.peerId), + ); + this.viewerRouteIntentsByRoom.get(input.roomId)?.delete(input.peerId); + this.relayCapacitySessionsByRoom.get(input.roomId)?.delete(input.peerId); + } + this.abortPendingRouteForTopologyChange(input.roomId, input.peerId); + const assignmentChanges = + input.role === "host" + ? this.peerRelayTopology.setHost( + input.roomId, + input.peerId, + this.connectedPeerIds(input.roomId), + ) + : this.peerRelayTopology.addViewer( + input.roomId, + input.peerId, + this.connectedPeerIds(input.roomId), + ); + this.clearChangedConnectionIds(input.roomId, assignmentChanges); + const controller = this.reconcileMediaRoute(input.roomId, input.peerId); + const activeRoute = controller?.getActiveRoute(); + const routeAssignment = + activeRoute?.assignments.get(input.peerId) ?? + participantRouteFromMediaAssignment( + this.peerRelayTopology.getAssignment(input.roomId, input.peerId), + ); + if (!routeAssignment) { + throw new Error("Peer relay topology has no participant assignment"); + } + return { + routeRevision: activeRoute?.revision ?? 0, + routeAssignment, + mediaAssignment: mediaAssignmentFromParticipantRoute(routeAssignment), + assignmentChanges, + activeRoute, + }; + } + + completeAuthentication( + participant: AuthenticatedRouteParticipant, + state: HybridAuthenticationState, + ): void { + const activeParentPeerId = + state.routeAssignment.upstream.kind === "peer" + ? state.routeAssignment.upstream.peerId + : undefined; + this.sendMediaAssignmentChanges( + participant.roomId, + state.assignmentChanges, + participant.peerId, + ); + if ( + participant.role === "viewer" && + activeParentPeerId && + !state.assignmentChanges.some( + ({ peerId }) => peerId === activeParentPeerId, + ) + ) { + this.sendCurrentParentAssignment(participant.roomId, participant.peerId); + } + if (state.activeRoute?.sfu.publicationGeneration) { + void this.sendFreshSfuConfig( + participant.roomId, + participant.peerId, + participant.sessionId, + state.activeRoute.revision, + ); + } + this.drainViewerRouteIntents(participant.roomId); + } + + isActivePeerParentOf( + roomId: string, + parentPeerId: string, + childPeerId: string, + ): boolean { + const controller = this.mediaRouteControllers.get(roomId); + if (!controller) { + return false; + } + const assignments = controller.getActiveRoute().assignments; + const child = assignments.get(childPeerId); + return ( + child?.upstream.kind === "peer" && + child.upstream.peerId === parentPeerId && + assignments.get(parentPeerId)?.childPeerIds.includes(childPeerId) === true + ); + } + + setViewerRelayCapacity( + participant: AuthenticatedRouteParticipant, + downstreamEdges: RelayDownstreamEdges, + ): void { + if (participant.role !== "viewer") { + return; + } + let advertised = this.relayCapacitySessionsByRoom.get(participant.roomId); + if (!advertised) { + advertised = new Map(); + this.relayCapacitySessionsByRoom.set(participant.roomId, advertised); + } + advertised.set(participant.peerId, participant.sessionId); + const changes = this.peerRelayTopology.setViewerRelayCapacity( + participant.roomId, + participant.peerId, + downstreamEdges, + this.connectedPeerIds(participant.roomId), + ); + if (changes.length > 0) { + this.clearChangedConnectionIds(participant.roomId, changes); + this.reconcileMediaRoute(participant.roomId); + this.sendMediaAssignmentChanges(participant.roomId, changes); + } + const assignment = this.mediaRouteControllers + .get(participant.roomId) + ?.getActiveRoute() + .assignments.get(participant.peerId); + let roomIntents = this.viewerRouteIntentsByRoom.get(participant.roomId); + if (assignment?.upstream.kind === "none") { + if (!roomIntents) { + roomIntents = new Map(); + this.viewerRouteIntentsByRoom.set(participant.roomId, roomIntents); + } + const existing = roomIntents.get(participant.peerId); + if ( + existing?.sessionId !== participant.sessionId || + existing.failedParentPeerId !== null + ) { + roomIntents.set(participant.peerId, { + failedParentPeerId: null, + sessionId: participant.sessionId, + sfuAttempts: 0, + unavailableReported: false, + }); + } + } else if ( + roomIntents?.get(participant.peerId)?.failedParentPeerId === null + ) { + roomIntents.delete(participant.peerId); + } + this.drainViewerRouteIntents(participant.roomId); + } + + handleRouteReady( + participant: AuthenticatedRouteParticipant, + message: Extract, + ): void { + const controller = this.mediaRouteControllers.get(participant.roomId); + if (!controller) { + return; + } + if (message.phase === "active") { + const active = controller.getActiveRoute(); + if ( + active.revision === message.revision && + active.assignments.has(participant.peerId) + ) { + return; + } + return; + } + + const pending = this.pendingRoutePreparations.get(participant.roomId); + const pendingRoute = controller.getPendingRoute(); + if ( + !pending || + !pendingRoute || + pending.revision !== message.revision || + pendingRoute.route.revision !== message.revision || + !pending.grantsIssued + ) { + return; + } + const expectedSessionId = pending.expectedSessionIds.get(participant.peerId); + if (!expectedSessionId) { + this.sendError( + participant.sessionId, + "FORBIDDEN", + "Participant is not part of this route plan", + ); + return; + } + if (participant.sessionId !== expectedSessionId) { + return; + } + + controller.ready(participant.peerId, message.revision, "prepare"); + this.commitPendingRoute(participant.roomId, message.revision); + } + + handleRouteFailed( + participant: AuthenticatedRouteParticipant, + message: Extract, + ): void { + if (message.phase === "prepare") { + this.handlePrepareRouteFailed(participant, message); + return; + } + const controller = this.mediaRouteControllers.get(participant.roomId); + const active = controller?.getActiveRoute(); + if (!controller || !active || active.revision !== message.revision) { + return; + } + if (message.connectionId === null) { + const assignment = active.assignments.get(participant.peerId); + const hostOwnsGeneration = + participant.role === "host" && + active.sfu.publicationGeneration !== null && + assignment?.sfuPublicationGeneration === + active.sfu.publicationGeneration; + const isSfuRoot = + assignment?.upstream.kind === "sfu" && + active.sfu.rootPeerIds.includes(participant.peerId); + if (!hostOwnsGeneration && !isSfuRoot) { + this.sendError( + participant.sessionId, + "FORBIDDEN", + "Participant does not own an active SFU route", + ); + return; + } + this.failBackToPeerBaseline(participant.roomId); + return; + } + if (participant.role !== "viewer") { + this.sendError( + participant.sessionId, + "FORBIDDEN", + "Only an active viewer route may fail", + ); + return; + } + const assignment = active.assignments.get(participant.peerId); + if (!assignment) { + return; + } + if (assignment.upstream.kind !== "peer") { + this.sendError( + participant.sessionId, + "FORBIDDEN", + "The active route has no peer edge to recover", + ); + return; + } + const connectionKey = viewerConnectionKey( + participant.roomId, + participant.peerId, + ); + if ( + this.options.getConnectionId(participant.roomId, participant.peerId) !== + message.connectionId + ) { + return; + } + + let failedParents = this.failedParentPeerIdsByViewer.get(connectionKey); + if (!failedParents) { + failedParents = new Set(); + this.failedParentPeerIdsByViewer.set(connectionKey, failedParents); + } + if (failedParents.has(assignment.upstream.peerId)) { + return; + } + failedParents.add(assignment.upstream.peerId); + let roomIntents = this.viewerRouteIntentsByRoom.get(participant.roomId); + if (!roomIntents) { + roomIntents = new Map(); + this.viewerRouteIntentsByRoom.set(participant.roomId, roomIntents); + } + roomIntents.set(participant.peerId, { + failedParentPeerId: assignment.upstream.peerId, + sessionId: participant.sessionId, + sfuAttempts: 0, + unavailableReported: false, + }); + this.drainViewerRouteIntents(participant.roomId); + } + + refreshSfu( + participant: AuthenticatedRouteParticipant, + revision: number, + ): void { + void this.sendFreshSfuConfig( + participant.roomId, + participant.peerId, + participant.sessionId, + revision, + true, + true, + ); + } + + disconnectParticipant(roomId: string): void { + const pending = this.pendingRoutePreparations.get(roomId); + if (!pending || this.pendingSessionsAreCurrent(roomId, pending)) { + return; + } + this.abortPendingRouteForTopologyChange(roomId); + } + + removeViewer(roomId: string, peerId: string): void { + this.abortPendingRouteForTopologyChange(roomId); + const changes = this.peerRelayTopology.removeViewer( + roomId, + peerId, + this.connectedPeerIds(roomId), + ); + this.clearChangedConnectionIds(roomId, changes); + this.reconcileMediaRoute(roomId); + this.sendMediaAssignmentChanges(roomId, changes); + this.failedParentPeerIdsByViewer.delete(viewerConnectionKey(roomId, peerId)); + this.viewerRouteIntentsByRoom.get(roomId)?.delete(peerId); + this.relayCapacitySessionsByRoom.get(roomId)?.delete(peerId); + this.clearSfuRefreshesForPeer(roomId, peerId); + this.drainViewerRouteIntents(roomId); + } + + stopRoom(roomId: string): void { + this.clearRoomMediaRouteState(roomId); + } + + deleteRoom(roomId: string): void { + this.peerRelayTopology.deleteRoom(roomId); + this.relayCapacitySessionsByRoom.delete(roomId); + this.clearRoomMediaRouteState(roomId); + } + + private handlePrepareRouteFailed( + participant: AuthenticatedRouteParticipant, + message: Extract, + ): void { + const controller = this.mediaRouteControllers.get(participant.roomId); + const pending = this.pendingRoutePreparations.get(participant.roomId); + const pendingRoute = controller?.getPendingRoute(); + if ( + !controller || + !pending || + !pendingRoute || + pending.revision !== message.revision || + pendingRoute.route.revision !== message.revision || + !pending.grantsIssued + ) { + return; + } + if (message.connectionId !== null) { + this.sendError( + participant.sessionId, + "FORBIDDEN", + "Prepare failures do not identify a peer connection", + ); + return; + } + const expectedSessionId = pending.expectedSessionIds.get(participant.peerId); + if (!expectedSessionId) { + this.sendError( + participant.sessionId, + "FORBIDDEN", + "Participant is not part of this route plan", + ); + return; + } + if (participant.sessionId !== expectedSessionId) { + return; + } + this.abortPendingRoute(participant.roomId); + } + + private failBackToPeerBaseline(roomId: string): void { + this.abortPendingRoute(roomId, undefined, false); + const controller = this.mediaRouteControllers.get(roomId); + const hostPeerId = this.peerRelayTopology.getHostPeerId(roomId); + if (!controller || !hostPeerId) { + return; + } + const before = controller.getActiveRoute(); + const fillChanges = this.peerRelayTopology.setHost( + roomId, + hostPeerId, + this.connectedPeerIds(roomId), + ); + this.clearChangedConnectionIds(roomId, fillChanges); + const assignments = participantRoutesFromTopology( + this.peerRelayTopology.getAssignments(roomId), + hostPeerId, + null, + [], + ); + const revision = controller.reconcileBaseline({ + assignments, + sfuPublicationGeneration: null, + sfuRootPeerIds: [], + }); + if (revision === undefined) { + return; + } + this.sfuDisabledRoomIds.add(roomId); + this.clearRoomFailedParents(roomId); + const active = controller.getActiveRoute(); + this.consumedSfuRefreshesByRoom.delete(roomId); + this.clearChangedRouteConnectionIds(roomId, before, active); + this.broadcastActiveRoute(roomId, active); + this.drainViewerRouteIntents(roomId); + } + + private retireShrinkingSfuRoute( + roomId: string, + survivingRootPeerIds: readonly string[], + ): void { + this.abortPendingRoute(roomId, undefined, false); + const controller = this.mediaRouteControllers.get(roomId); + const hostPeerId = this.peerRelayTopology.getHostPeerId(roomId); + if (!controller || !hostPeerId) { + return; + } + const before = controller.getActiveRoute(); + const assignments = participantRoutesFromTopology( + this.peerRelayTopology.getAssignments(roomId), + hostPeerId, + null, + [], + ); + const revision = controller.reconcileBaseline({ + assignments, + sfuPublicationGeneration: null, + sfuRootPeerIds: [], + }); + if (revision === undefined) { + return; + } + this.consumedSfuRefreshesByRoom.delete(roomId); + const active = controller.getActiveRoute(); + this.clearChangedRouteConnectionIds(roomId, before, active); + this.broadcastActiveRoute(roomId, active); + + for (const peerId of survivingRootPeerIds) { + const viewer = this.options.roomStore.getConnectedViewer(roomId, peerId); + const assignment = active.assignments.get(peerId); + if (!viewer || !assignment || assignment.upstream.kind === "sfu") { + continue; + } + const failedParentPeerId = + assignment.upstream.kind === "peer" && + this.failedParentPeerIdsByViewer + .get(viewerConnectionKey(roomId, peerId)) + ?.has(assignment.upstream.peerId) + ? assignment.upstream.peerId + : assignment.upstream.kind === "none" + ? null + : undefined; + if (failedParentPeerId === undefined) { + continue; + } + let intents = this.viewerRouteIntentsByRoom.get(roomId); + if (!intents) { + intents = new Map(); + this.viewerRouteIntentsByRoom.set(roomId, intents); + } + intents.set(peerId, { + failedParentPeerId, + sessionId: viewer.sessionId, + sfuAttempts: 0, + unavailableReported: false, + }); + } + this.drainViewerRouteIntents(roomId); + } + + private drainViewerRouteIntents(roomId: string): void { + this.enqueueUnassignedViewerIntents(roomId); + if (this.pendingRoutePreparations.has(roomId)) { + return; + } + const intents = this.viewerRouteIntentsByRoom.get(roomId); + if (!intents) { + return; + } + + for (const [viewerPeerId, intent] of [...intents]) { + const viewer = this.options.roomStore.getConnectedViewer( + roomId, + viewerPeerId, + ); + const controller = this.mediaRouteControllers.get(roomId); + const assignment = controller + ?.getActiveRoute() + .assignments.get(viewerPeerId); + if (viewer?.sessionId !== intent.sessionId || !assignment) { + intents.delete(viewerPeerId); + continue; + } + if (intent.failedParentPeerId === null) { + if (assignment.upstream.kind !== "none") { + intents.delete(viewerPeerId); + continue; + } + const active = controller!.getActiveRoute(); + const excludedParents = new Set(); + const baselineParentPeerId = this.peerRelayTopology.getAssignment( + roomId, + viewerPeerId, + )?.parentPeerId; + if (baselineParentPeerId) { + excludedParents.add(baselineParentPeerId); + } + const hostPeerId = this.peerRelayTopology.getHostPeerId(roomId); + if ( + active.sfu.publicationGeneration && + hostPeerId && + (active.assignments.get(hostPeerId)?.childPeerIds.length ?? 0) >= 1 + ) { + excludedParents.add(hostPeerId); + } + const changes = this.peerRelayTopology.reassignViewer( + roomId, + viewerPeerId, + this.connectedPeerIds(roomId), + excludedParents, + MAX_PEER_RELAY_DEPTH, + ); + if (changes) { + intents.delete(viewerPeerId); + this.clearChangedConnectionIds(roomId, changes); + this.reconcileMediaRoute(roomId); + this.sendMediaAssignmentChanges(roomId, changes); + continue; + } + } else { + if ( + assignment.upstream.kind !== "peer" || + assignment.upstream.peerId !== intent.failedParentPeerId + ) { + intents.delete(viewerPeerId); + continue; + } + const failedParents = this.failedParentPeerIdsByViewer.get( + viewerConnectionKey(roomId, viewerPeerId), + ); + if (!failedParents?.has(intent.failedParentPeerId)) { + intents.delete(viewerPeerId); + continue; + } + const changes = this.peerRelayTopology.reassignViewer( + roomId, + viewerPeerId, + this.connectedPeerIds(roomId), + failedParents, + MAX_PEER_RELAY_DEPTH, + ); + if (changes) { + intents.delete(viewerPeerId); + this.clearChangedConnectionIds(roomId, changes); + this.reconcileMediaRoute(roomId); + this.sendMediaAssignmentChanges(roomId, changes); + continue; + } + } + + if ( + intent.sfuAttempts === 0 && + this.options.sfuFallback && + !this.sfuDisabledRoomIds.has(roomId) + ) { + const result = this.prepareSfuFallback( + roomId, + viewerPeerId, + intent.sessionId, + ); + if (result === "started") { + intent.sfuAttempts += 1; + return; + } + if (result === "busy") { + return; + } + intent.unavailableReported = true; + } + + if (!intent.unavailableReported) { + this.sendError( + intent.sessionId, + "PEER_NOT_FOUND", + "No media fallback route is available", + ); + intent.unavailableReported = true; + } + } + if (intents.size === 0) { + this.viewerRouteIntentsByRoom.delete(roomId); + } + } + + private prepareSfuFallback( + roomId: string, + failedPeerId: string, + sourceSessionId: string, + ): SfuPrepareResult { + const fallback = this.options.sfuFallback; + const controller = this.mediaRouteControllers.get(roomId); + if (!fallback || !controller) { + return "unavailable"; + } + if (controller.getPendingRoute()) { + return "busy"; + } + if (this.sfuDisabledRoomIds.has(roomId)) { + this.sendError( + sourceSessionId, + "PEER_NOT_FOUND", + "No media fallback route is available", + ); + return "unavailable"; + } + const active = controller.getActiveRoute(); + const requestedAssignment = active.assignments.get(failedPeerId); + if ( + !requestedAssignment || + requestedAssignment.upstream.kind === "sfu" || + failedPeerId === this.peerRelayTopology.getHostPeerId(roomId) + ) { + this.sendError( + sourceSessionId, + "PEER_NOT_FOUND", + "Viewer has no SFU fallback route", + ); + return "unavailable"; + } + const hostPeerId = this.peerRelayTopology.getHostPeerId(roomId); + if (!hostPeerId) { + this.sendError( + sourceSessionId, + "SERVER_ERROR", + "SFU fallback plan is invalid", + ); + return "unavailable"; + } + + const rootPeerIds = [ + ...new Set([...active.sfu.rootPeerIds, failedPeerId]), + ]; + const assignments = cloneAssignments(active.assignments); + if (!promoteSfuRoots(assignments, rootPeerIds)) { + this.sendError( + sourceSessionId, + "SERVER_ERROR", + "SFU fallback plan is invalid", + ); + return "unavailable"; + } + + let host = assignments.get(hostPeerId); + if (!host) { + this.sendError(sourceSessionId, "SERVER_ERROR", "Media route has no host"); + return "unavailable"; + } + + if (host.childPeerIds.length > 1) { + const budgetRootPeerId = + requestedAssignment.upstream.kind === "peer" + ? findHostBranchRoot(active.assignments, failedPeerId, hostPeerId) + : host.childPeerIds[0]; + if ( + !budgetRootPeerId || + budgetRootPeerId === hostPeerId || + (!rootPeerIds.includes(budgetRootPeerId) && + rootPeerIds.length >= fallback.maxRoots) + ) { + this.sendError( + sourceSessionId, + "PEER_NOT_FOUND", + "SFU fallback root budget is exhausted", + ); + return "unavailable"; + } + if (!rootPeerIds.includes(budgetRootPeerId)) { + rootPeerIds.push(budgetRootPeerId); + } + if (!promoteSfuRoots(assignments, [budgetRootPeerId])) { + this.sendError( + sourceSessionId, + "SERVER_ERROR", + "SFU fallback plan is invalid", + ); + return "unavailable"; + } + host = assignments.get(hostPeerId); + } + + if ( + rootPeerIds.length > fallback.maxRoots || + !host || + host.childPeerIds.length + 1 > 2 + ) { + this.sendError( + sourceSessionId, + "PEER_NOT_FOUND", + "SFU fallback root budget is exhausted", + ); + return "unavailable"; + } + + const generation = randomBytes(16).toString("base64url"); + assignments.set(hostPeerId, { + ...host, + sfuPublicationGeneration: generation, + }); + + const expectedSessionIds = new Map(); + for (const peerId of [hostPeerId, ...rootPeerIds]) { + const peer = this.connectedPeer(roomId, peerId); + if (!peer) { + this.sendError( + sourceSessionId, + "PEER_NOT_FOUND", + "SFU route participant is offline", + ); + return "unavailable"; + } + expectedSessionIds.set(peerId, peer.sessionId); + } + let revision: number | undefined; + try { + revision = controller.prepare({ + assignments, + expectedParticipantIds: new Set(expectedSessionIds.keys()), + sfuPublicationGeneration: generation, + sfuRootPeerIds: rootPeerIds, + }); + } catch { + this.sendError( + sourceSessionId, + "SERVER_ERROR", + "SFU fallback plan is invalid", + ); + return "unavailable"; + } + if (revision === undefined) { + return "unavailable"; + } + + const timer = setTimeout( + () => this.abortPendingRoute(roomId), + fallback.prepareTimeoutMs ?? DEFAULT_SFU_PREPARE_TIMEOUT_MS, + ); + timer.unref(); + this.pendingRoutePreparations.set(roomId, { + revision, + intentPeerId: failedPeerId, + expectedSessionIds, + grantsIssued: false, + timer, + }); + void this.issueSfuPrepareGrants( + roomId, + controller, + revision, + generation, + rootPeerIds, + expectedSessionIds, + ); + return "started"; + } + + private async issueSfuPrepareGrants( + roomId: string, + controller: MediaRouteController, + revision: number, + generation: string, + rootPeerIds: readonly string[], + expectedSessionIds: ReadonlyMap, + ): Promise { + const fallback = this.options.sfuFallback; + if (!fallback) { + return; + } + try { + const grants = await Promise.all( + [...expectedSessionIds].map(async ([peerId, sessionId]) => ({ + peerId, + sessionId, + token: await fallback.tokenIssuer.issueToken({ + roomId, + role: + peerId === this.peerRelayTopology.getHostPeerId(roomId) + ? "host" + : "viewer", + peerId, + publicationGeneration: generation, + allowlistedRootPeerIds: rootPeerIds, + }), + })), + ); + const pending = this.pendingRoutePreparations.get(roomId); + const pendingRoute = controller.getPendingRoute(); + if ( + this.mediaRouteControllers.get(roomId) !== controller || + pending?.revision !== revision || + pendingRoute?.route.revision !== revision + ) { + return; + } + if (!this.pendingSessionsAreCurrent(roomId, pending)) { + this.abortPendingRoute(roomId); + return; + } + for (const { peerId, sessionId, token } of grants) { + const assignment = pendingRoute.route.assignments.get(peerId); + if (!assignment) { + this.abortPendingRoute(roomId); + return; + } + this.options.sendToSession(sessionId, { + type: "route-update", + revision, + phase: "prepare", + assignment, + }); + this.options.sendToSession(sessionId, { + type: "sfu-config", + revision, + url: fallback.url, + token, + }); + } + pending.grantsIssued = true; + } catch { + console.error("SFU route preparation failed"); + const pending = this.pendingRoutePreparations.get(roomId); + if ( + this.mediaRouteControllers.get(roomId) === controller && + pending?.revision === revision + ) { + this.abortPendingRoute(roomId); + } + } + } + + private commitPendingRoute(roomId: string, revision: number): void { + const controller = this.mediaRouteControllers.get(roomId); + const pending = this.pendingRoutePreparations.get(roomId); + if ( + !controller || + !pending || + pending.revision !== revision || + !this.pendingSessionsAreCurrent(roomId, pending) + ) { + if (pending?.revision === revision) { + this.abortPendingRoute(roomId); + } + return; + } + const before = controller.getActiveRoute(); + if (!controller.commit(revision)) { + return; + } + clearTimeout(pending.timer); + this.pendingRoutePreparations.delete(roomId); + const active = controller.getActiveRoute(); + this.clearChangedRouteConnectionIds(roomId, before, active); + this.broadcastActiveRoute(roomId, active); + this.drainViewerRouteIntents(roomId); + } + + private abortPendingRoute( + roomId: string, + excludedPeerId?: string, + drainFailureIntents = true, + ): void { + const controller = this.mediaRouteControllers.get(roomId); + const pending = this.pendingRoutePreparations.get(roomId); + if (!controller || !pending) { + return; + } + clearTimeout(pending.timer); + this.pendingRoutePreparations.delete(roomId); + if (controller.abort(pending.revision)) { + this.broadcastActiveRoute( + roomId, + controller.getActiveRoute(), + excludedPeerId, + ); + } + if (drainFailureIntents) { + this.drainViewerRouteIntents(roomId); + } + } + + private abortPendingRouteForTopologyChange( + roomId: string, + excludedPeerId?: string, + ): void { + const pending = this.pendingRoutePreparations.get(roomId); + const intent = pending + ? this.viewerRouteIntentsByRoom + .get(roomId) + ?.get(pending.intentPeerId) + : undefined; + if (intent) { + intent.sfuAttempts = 0; + intent.unavailableReported = false; + } + this.abortPendingRoute(roomId, excludedPeerId, false); + } + + private pendingSessionsAreCurrent( + roomId: string, + pending: PendingRoutePreparation, + ): boolean { + for (const [peerId, sessionId] of pending.expectedSessionIds) { + if (this.connectedPeer(roomId, peerId)?.sessionId !== sessionId) { + return false; + } + } + return true; + } + + private async sendFreshSfuConfig( + roomId: string, + peerId: string, + sessionId: string, + revision: number, + reportError = false, + consumeRefresh = false, + ): Promise { + const fallback = this.options.sfuFallback; + const controller = this.mediaRouteControllers.get(roomId); + const active = controller?.getActiveRoute(); + if ( + !fallback || + !controller || + !active || + active.revision !== revision || + !active.sfu.publicationGeneration || + (peerId !== this.peerRelayTopology.getHostPeerId(roomId) && + !active.sfu.rootPeerIds.includes(peerId)) + ) { + if (reportError) { + this.sendError( + sessionId, + "FORBIDDEN", + "Participant has no active SFU grant", + ); + } + return; + } + if (consumeRefresh) { + let consumed = this.consumedSfuRefreshesByRoom.get(roomId); + if (!consumed) { + consumed = new Set(); + this.consumedSfuRefreshesByRoom.set(roomId, consumed); + } + const refreshKey = sfuRefreshKey( + active.sfu.publicationGeneration, + peerId, + sessionId, + ); + if (consumed.has(refreshKey)) { + this.sendError( + sessionId, + "FORBIDDEN", + "The active SFU grant was already refreshed", + ); + return; + } + consumed.add(refreshKey); + } + try { + const token = await fallback.tokenIssuer.issueToken({ + roomId, + role: + peerId === this.peerRelayTopology.getHostPeerId(roomId) + ? "host" + : "viewer", + peerId, + publicationGeneration: active.sfu.publicationGeneration, + allowlistedRootPeerIds: active.sfu.rootPeerIds, + }); + const current = controller.getActiveRoute(); + if ( + this.mediaRouteControllers.get(roomId) !== controller || + this.connectedPeer(roomId, peerId)?.sessionId !== sessionId || + current.revision !== revision || + current.sfu.publicationGeneration !== + active.sfu.publicationGeneration + ) { + return; + } + this.options.sendToSession(sessionId, { + type: "sfu-config", + revision, + url: fallback.url, + token, + }); + } catch { + console.error("SFU token refresh failed"); + const current = controller.getActiveRoute(); + if ( + this.mediaRouteControllers.get(roomId) === controller && + this.connectedPeer(roomId, peerId)?.sessionId === sessionId && + current.revision === revision && + current.sfu.publicationGeneration === + active.sfu.publicationGeneration + ) { + this.failBackToPeerBaseline(roomId); + } + } + } + + private reconcileMediaRoute( + roomId: string, + excludedPeerId?: string, + ): MediaRouteController | undefined { + const hostPeerId = this.peerRelayTopology.getHostPeerId(roomId); + if (!hostPeerId) { + return undefined; + } + + let controller = this.mediaRouteControllers.get(roomId); + if (controller?.getPendingRoute()) { + this.abortPendingRouteForTopologyChange(roomId, excludedPeerId); + controller = this.mediaRouteControllers.get(roomId); + } + let initialRevision = 0; + if (controller && !controller.getActiveRoute().assignments.has(hostPeerId)) { + initialRevision = controller.getActiveRoute().revision; + if (initialRevision >= MAX_MEDIA_ROUTE_REVISION) { + throw new Error("Media route revision space exhausted"); + } + initialRevision += 1; + this.clearRoomMediaRouteState(roomId); + controller = undefined; + } + const previousSfu = controller?.getActiveRoute().sfu; + let topologyAssignments = this.peerRelayTopology.getAssignments(roomId); + let rootPeerIds = + previousSfu?.rootPeerIds.filter((peerId) => + topologyAssignments.has(peerId), + ) ?? []; + if ( + previousSfu?.publicationGeneration && + rootPeerIds.length !== previousSfu.rootPeerIds.length + ) { + this.retireShrinkingSfuRoute(roomId, rootPeerIds); + return controller; + } + const assignments = participantRoutesFromTopology( + topologyAssignments, + hostPeerId, + previousSfu?.publicationGeneration ?? null, + rootPeerIds, + ); + if (!controller && initialRevision === 0 && assignments.size > 1) { + initialRevision = 1; + } + + if (!controller) { + controller = new MediaRouteController({ + hostPeerId, + assignments, + revision: initialRevision, + sfuPublicationGeneration: + assignments.get(hostPeerId)?.sfuPublicationGeneration ?? null, + sfuRootPeerIds: [...assignments] + .filter(([, assignment]) => assignment.upstream.kind === "sfu") + .map(([peerId]) => peerId), + }); + this.mediaRouteControllers.set(roomId, controller); + this.broadcastActiveRoute( + roomId, + controller.getActiveRoute(), + excludedPeerId, + ); + return controller; + } + + const publicationGeneration = + rootPeerIds.length > 0 + ? (previousSfu?.publicationGeneration ?? null) + : null; + const revision = controller.reconcileBaseline({ + assignments, + sfuPublicationGeneration: publicationGeneration, + sfuRootPeerIds: rootPeerIds, + }); + if (revision !== undefined) { + this.broadcastActiveRoute( + roomId, + controller.getActiveRoute(), + excludedPeerId, + ); + } + return controller; + } + + private broadcastActiveRoute( + roomId: string, + route: RoomMediaRoute, + excludedPeerId?: string, + ): void { + for (const [peerId, assignment] of route.assignments) { + if (peerId === excludedPeerId) { + continue; + } + const peer = this.connectedPeer(roomId, peerId); + if (peer) { + this.options.sendToSession(peer.sessionId, { + type: "route-update", + revision: route.revision, + phase: "active", + assignment, + }); + } + } + } + + private clearChangedRouteConnectionIds( + roomId: string, + before: RoomMediaRoute, + after: RoomMediaRoute, + ): void { + for (const [peerId, assignment] of after.assignments) { + const previous = before.assignments.get(peerId); + if (!previous || !sameUpstream(previous, assignment)) { + this.options.deleteConnectionId(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.options.deleteConnectionId(roomId, change.peerId); + } + } + } + + private sendMediaAssignmentChanges( + roomId: string, + changes: readonly MediaAssignmentChange[], + excludedPeerId?: string, + ): void { + const active = this.mediaRouteControllers.get(roomId)?.getActiveRoute(); + for (const change of changes) { + if (change.peerId === excludedPeerId) { + continue; + } + const peer = this.connectedPeer(roomId, change.peerId); + if (peer) { + const activeAssignment = active?.assignments.get(change.peerId); + this.options.sendToSession(peer.sessionId, { + type: "media-assignment", + mediaAssignment: activeAssignment + ? mediaAssignmentFromParticipantRoute(activeAssignment) + : change.mediaAssignment, + }); + } + } + } + + private sendCurrentParentAssignment(roomId: string, childPeerId: string): void { + const active = this.mediaRouteControllers.get(roomId)?.getActiveRoute(); + const child = active?.assignments.get(childPeerId); + const parentPeerId = + child?.upstream.kind === "peer" ? child.upstream.peerId : undefined; + if (!parentPeerId) { + return; + } + const parent = this.connectedPeer(roomId, parentPeerId); + const parentAssignment = active?.assignments.get(parentPeerId); + if (parent && parentAssignment && active) { + this.options.sendToSession(parent.sessionId, { + type: "route-update", + revision: active.revision, + phase: "active", + assignment: parentAssignment, + }); + this.options.sendToSession(parent.sessionId, { + type: "media-assignment", + mediaAssignment: mediaAssignmentFromParticipantRoute(parentAssignment), + }); + } + } + + private connectedPeerIds(roomId: string): Set { + 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) { + return host; + } + return this.options.roomStore.getConnectedViewer(roomId, peerId); + } + + private sendError(sessionId: string, code: ErrorCode, message: string): void { + this.options.sendToSession(sessionId, { type: "error", code, message }); + } + + private clearRoomFailedParents(roomId: string): void { + const prefix = `${roomId}:`; + for (const key of this.failedParentPeerIdsByViewer.keys()) { + if (key.startsWith(prefix)) { + this.failedParentPeerIdsByViewer.delete(key); + } + } + } + + private clearRoomMediaRouteState(roomId: string): void { + const pending = this.pendingRoutePreparations.get(roomId); + if (pending) { + clearTimeout(pending.timer); + this.pendingRoutePreparations.delete(roomId); + } + this.mediaRouteControllers.delete(roomId); + this.clearRoomFailedParents(roomId); + this.viewerRouteIntentsByRoom.delete(roomId); + this.consumedSfuRefreshesByRoom.delete(roomId); + this.sfuDisabledRoomIds.delete(roomId); + } + + private clearSfuRefreshesForPeer(roomId: string, peerId: string): void { + const consumed = this.consumedSfuRefreshesByRoom.get(roomId); + if (!consumed) { + return; + } + const marker = `\u0000${peerId}\u0000`; + for (const key of consumed) { + if (key.includes(marker)) { + consumed.delete(key); + } + } + if (consumed.size === 0) { + this.consumedSfuRefreshesByRoom.delete(roomId); + } + } + + private enqueueUnassignedViewerIntents(roomId: string): void { + const advertised = this.relayCapacitySessionsByRoom.get(roomId); + const assignments = this.mediaRouteControllers + .get(roomId) + ?.getActiveRoute().assignments; + if (!advertised || !assignments) { + return; + } + let intents = this.viewerRouteIntentsByRoom.get(roomId); + for (const [peerId, sessionId] of advertised) { + const viewer = this.options.roomStore.getConnectedViewer(roomId, peerId); + if (viewer?.sessionId !== sessionId) { + advertised.delete(peerId); + intents?.delete(peerId); + continue; + } + if (assignments.get(peerId)?.upstream.kind !== "none") { + continue; + } + if (!intents) { + intents = new Map(); + this.viewerRouteIntentsByRoom.set(roomId, intents); + } + const existing = intents.get(peerId); + if ( + existing?.sessionId === sessionId && + existing.failedParentPeerId === null + ) { + continue; + } + intents.set(peerId, { + failedParentPeerId: null, + sessionId, + sfuAttempts: 0, + unavailableReported: false, + }); + } + } +} + +function viewerConnectionKey(roomId: string, peerId: string): string { + return `${roomId}:${peerId}`; +} + +function sfuRefreshKey( + publicationGeneration: string, + peerId: string, + sessionId: string, +): string { + return `${publicationGeneration}\u0000${peerId}\u0000${sessionId}`; +} + +function participantRouteFromMediaAssignment( + assignment: MediaAssignment | undefined, +): ParticipantRouteAssignment | undefined { + return assignment + ? { + upstream: assignment.parentPeerId + ? { kind: "peer", peerId: assignment.parentPeerId } + : { kind: "none" }, + childPeerIds: [...assignment.childPeerIds], + sfuPublicationGeneration: null, + } + : undefined; +} + +function mediaAssignmentFromParticipantRoute( + assignment: ParticipantRouteAssignment, +): MediaAssignment { + return { + parentPeerId: + assignment.upstream.kind === "peer" + ? assignment.upstream.peerId + : null, + childPeerIds: [...assignment.childPeerIds], + }; +} + +function participantRoutesFromTopology( + topologyAssignments: ReadonlyMap, + hostPeerId: string, + publicationGeneration: string | null, + requestedRootPeerIds: readonly string[], +): Map { + // Keep the peer tree intact underneath the active SFU overlay so prepare + // aborts and one-shot failback never need to reconstruct a lost baseline. + const assignments = new Map(); + for (const [peerId, assignment] of topologyAssignments) { + assignments.set(peerId, { + upstream: + peerId === hostPeerId || !assignment.parentPeerId + ? { kind: "none" } + : { kind: "peer", peerId: assignment.parentPeerId }, + childPeerIds: [...assignment.childPeerIds], + sfuPublicationGeneration: null, + }); + } + + const rootPeerIds = publicationGeneration + ? requestedRootPeerIds.filter( + (peerId) => peerId !== hostPeerId && assignments.has(peerId), + ) + : []; + for (const rootPeerId of rootPeerIds) { + const root = assignments.get(rootPeerId)!; + if (root.upstream.kind === "peer") { + const parent = assignments.get(root.upstream.peerId); + if (parent) { + assignments.set(root.upstream.peerId, { + ...parent, + childPeerIds: parent.childPeerIds.filter( + (childPeerId) => childPeerId !== rootPeerId, + ), + }); + } + } + assignments.set(rootPeerId, { + ...root, + upstream: { kind: "sfu" }, + }); + } + const host = assignments.get(hostPeerId); + if (host && rootPeerIds.length > 0) { + const directChildPeerIds = host.childPeerIds.slice(0, 1); + for (const suppressedPeerId of host.childPeerIds.slice(1)) { + const suppressed = assignments.get(suppressedPeerId); + if (suppressed?.upstream.kind === "peer") { + assignments.set(suppressedPeerId, { + ...suppressed, + upstream: { kind: "none" }, + }); + } + } + assignments.set(hostPeerId, { + ...host, + childPeerIds: directChildPeerIds, + sfuPublicationGeneration: publicationGeneration, + }); + } + return assignments; +} + +function cloneAssignments( + assignments: ReadonlyMap, +): Map { + return new Map( + [...assignments].map(([peerId, assignment]) => [ + peerId, + { + upstream: + assignment.upstream.kind === "peer" + ? { kind: "peer" as const, peerId: assignment.upstream.peerId } + : { kind: assignment.upstream.kind }, + childPeerIds: [...assignment.childPeerIds], + sfuPublicationGeneration: assignment.sfuPublicationGeneration, + }, + ]), + ); +} + +function promoteSfuRoots( + assignments: Map, + rootPeerIds: readonly string[], +): boolean { + for (const rootPeerId of rootPeerIds) { + const root = assignments.get(rootPeerId); + if (!root) { + return false; + } + if (root.upstream.kind === "peer") { + const parent = assignments.get(root.upstream.peerId); + if (!parent) { + return false; + } + assignments.set(root.upstream.peerId, { + ...parent, + childPeerIds: parent.childPeerIds.filter( + (childPeerId) => childPeerId !== rootPeerId, + ), + }); + } + assignments.set(rootPeerId, { + ...root, + upstream: { kind: "sfu" }, + }); + } + return true; +} + +function findHostBranchRoot( + assignments: ReadonlyMap, + peerId: string, + hostPeerId: string, +): string | undefined { + const visited = new Set(); + let branchPeerId = peerId; + while (!visited.has(branchPeerId)) { + visited.add(branchPeerId); + const assignment = assignments.get(branchPeerId); + if (assignment?.upstream.kind !== "peer") { + return undefined; + } + if (assignment.upstream.peerId === hostPeerId) { + return branchPeerId; + } + branchPeerId = assignment.upstream.peerId; + } + return undefined; +} + +function sameUpstream( + 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)) + ); +} diff --git a/src/server/livekit-token.ts b/src/server/livekit-token.ts new file mode 100644 index 0000000..88f729a --- /dev/null +++ b/src/server/livekit-token.ts @@ -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; +} + +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 { + 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}`; +} diff --git a/src/server/media-route-controller.ts b/src/server/media-route-controller.ts new file mode 100644 index 0000000..581c60d --- /dev/null +++ b/src/server/media-route-controller.ts @@ -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; + sfu: RoomSfuRoute; +} + +export interface PendingRoomMediaRoute { + route: RoomMediaRoute; + expectedParticipantIds: ReadonlySet; + readyParticipantIds: ReadonlySet; +} + +export interface MediaRouteControllerOptions { + hostPeerId: string; + assignments: ReadonlyMap; + revision?: number; + sfuPublicationGeneration?: string | null; + sfuRootPeerIds?: readonly string[]; +} + +export interface PrepareMediaRouteInput { + assignments: ReadonlyMap; + expectedParticipantIds: ReadonlySet; + sfuPublicationGeneration?: string | null; + sfuRootPeerIds?: readonly string[]; +} + +export interface ReconcileBaselineInput { + assignments: ReadonlyMap; + sfuPublicationGeneration?: string | null; + sfuRootPeerIds?: readonly string[]; +} + +interface StoredPendingRoute { + route: RoomMediaRoute; + expectedParticipantIds: Set; + readyParticipantIds: Set; +} + +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, + 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(); + 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, +): 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"); + } +} diff --git a/src/server/peer-relay-topology.ts b/src/server/peer-relay-topology.ts index ccaf9b8..e99b6ff 100644 --- a/src/server/peer-relay-topology.ts +++ b/src/server/peer-relay-topology.ts @@ -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, + ): 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 { + 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, + excludedParentPeerIds: ReadonlySet, + 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, + 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, + excludedParentPeerIds: ReadonlySet, + subtreePeerIds: ReadonlySet, + 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 { + const peerIds = new Set(); + 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, +): 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; diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 5951a24..6811a1b 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -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["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; authorizeUpgrade: (request: IncomingMessage) => boolean; @@ -71,7 +73,8 @@ export class SignalingServer { string, QualityProfileId >(); - private readonly peerRelayTopology = new PeerRelayTopology(); + private readonly shareGenerationsByRoom = new Map(); + 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 { - 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) { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 77f52bd..c5ec504 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -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; export const DEFAULT_QUALITY_PROFILE_ID: QualityProfileId = "1080p60"; +export const relayDownstreamEdgesSchema = z.union([ + z.literal(0), + z.literal(1), +]); +export type RelayDownstreamEdges = z.infer; + const iceServerSchema = z .object({ urls: z.union([ @@ -95,6 +103,43 @@ export const mediaAssignmentSchema = z .strict(); export type MediaAssignment = z.infer; +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; + +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; + +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"), diff --git a/tests/livekit-token.test.ts b/tests/livekit-token.test.ts new file mode 100644 index 0000000..e230daa --- /dev/null +++ b/tests/livekit-token.test.ts @@ -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); + }); +}); diff --git a/tests/media-route-controller.test.ts b/tests/media-route-controller.test.ts new file mode 100644 index 0000000..cb9dbc7 --- /dev/null +++ b/tests/media-route-controller.test.ts @@ -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 { + 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 { + 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([ + [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; + } + }); +}); diff --git a/tests/peer-relay-topology.test.ts b/tests/peer-relay-topology.test.ts index 5d5c7a9..3213388 100644 --- a/tests/peer-relay-topology.test.ts +++ b/tests/peer-relay-topology.test.ts @@ -6,6 +6,24 @@ function connected(...peerIds: string[]): Set { return new Set(peerIds); } +function addRelayViewer( + topology: PeerRelayTopology, + roomId: string, + peerId: string, + connectedPeerIds: ReadonlySet, +) { + 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"), diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index d8f605c..b0de179 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -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) => { diff --git a/tests/relay-capability.test.ts b/tests/relay-capability.test.ts new file mode 100644 index 0000000..64cf410 --- /dev/null +++ b/tests/relay-capability.test.ts @@ -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(); + }); +}); diff --git a/tests/route-transition.test.ts b/tests/route-transition.test.ts new file mode 100644 index 0000000..c71bbde --- /dev/null +++ b/tests/route-transition.test.ts @@ -0,0 +1,1029 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { + ClientMessage, + ParticipantRouteAssignment, +} from "../src/shared/protocol.ts"; +import { + MediaRouteTransition, + reportActivePeerRouteFailure, +} from "../src/client/media/route-transition.ts"; +import { HostSfuRoute } from "../src/client/media/host-sfu-route.ts"; +import { ViewerSfuRoute } from "../src/client/media/viewer-sfu-route.ts"; +import { QUALITY_PROFILES } from "../src/client/media/quality.ts"; + +const peerAssignment = ( + parentPeerId: string, + childPeerIds: string[] = [], +): ParticipantRouteAssignment => ({ + upstream: { kind: "peer", peerId: parentPeerId }, + childPeerIds, + sfuPublicationGeneration: null, +}); + +const sfuAssignment = (childPeerIds: string[] = []): ParticipantRouteAssignment => ({ + upstream: { kind: "sfu" }, + childPeerIds, + sfuPublicationGeneration: null, +}); + +const hostAssignment = ( + publicationGeneration: string | null, + childPeerIds: string[] = [], +): ParticipantRouteAssignment => ({ + upstream: { kind: "none" }, + childPeerIds, + sfuPublicationGeneration: publicationGeneration, +}); + +function sfuConfig(revision: number) { + return { + type: "sfu-config" as const, + revision, + url: "wss://sfu.example.test", + token: `token-${revision}`, + }; +} + +function createFakePublisher(log: string[], label: string) { + return { + connect: vi.fn(async () => { + log.push(`${label}:connect`); + return true; + }), + activate: vi.fn(async () => { + log.push(`${label}:activate`); + return true; + }), + deactivate: vi.fn(async () => { + log.push(`${label}:deactivate`); + return true; + }), + replaceStream: vi.fn(async () => true), + updateProfile: vi.fn(async () => true), + disconnect: vi.fn(async () => { + log.push(`${label}:disconnect`); + }), + }; +} + +interface FakeSubscriberEvents { + onStream: (stream: MediaStream | null) => void; + onDisconnected: () => void; +} + +function createFakeSubscriber( + events: FakeSubscriberEvents, + log: string[], + label: string, +) { + return { + events, + connect: vi.fn(async () => { + log.push(`${label}:connect`); + return true; + }), + activate: vi.fn(() => { + log.push(`${label}:activate`); + return true; + }), + deactivate: vi.fn(() => { + log.push(`${label}:deactivate`); + return true; + }), + disconnect: vi.fn(async () => { + log.push(`${label}:disconnect`); + }), + }; +} + +describe("MediaRouteTransition", () => { + it("keeps current media through prepare and until an active SFU video track", () => { + const route = new MediaRouteTransition(); + const direct = peerAssignment("host_12345678", ["child_a_12345678"]); + const sfu = sfuAssignment(["child_a_12345678"]); + + expect(route.accept({ revision: 1, phase: "active", assignment: direct })).toBe( + "accepted", + ); + const directToken = route.token()!; + expect(route.markMediaActive(directToken)).toBe(true); + + expect(route.accept({ revision: 2, phase: "prepare", assignment: sfu })).toBe( + "accepted", + ); + expect(route.getActiveAssignment()).toEqual(direct); + expect(route.getMediaAssignment()).toEqual(direct); + + expect(route.accept({ revision: 2, phase: "active", assignment: sfu })).toBe( + "accepted", + ); + const sfuToken = route.token()!; + expect(route.getActiveAssignment()).toEqual(sfu); + expect(route.getMediaAssignment()).toEqual(direct); + + expect(route.markMediaActive(sfuToken)).toBe(true); + expect(route.getMediaAssignment()).toEqual(sfu); + }); + + it("rejects stale config and async work after a rollback revision", () => { + const route = new MediaRouteTransition(); + const sfu = sfuAssignment(); + const direct = peerAssignment("host_12345678"); + + route.accept({ revision: 4, phase: "prepare", assignment: sfu }); + const staleToken = route.token()!; + expect(route.acceptsConfig(4)).toBe(true); + + route.accept({ revision: 5, phase: "active", assignment: direct }); + + expect(route.acceptsConfig(4)).toBe(false); + expect(route.owns(staleToken)).toBe(false); + expect(route.markMediaActive(staleToken)).toBe(false); + }); + + it("is idempotent for duplicates and rejects conflicting same revisions", () => { + const route = new MediaRouteTransition(); + const direct = peerAssignment("host_12345678"); + + expect(route.accept({ revision: 7, phase: "prepare", assignment: direct })).toBe( + "accepted", + ); + const token = route.token(); + expect(route.accept({ revision: 7, phase: "prepare", assignment: direct })).toBe( + "duplicate", + ); + expect(route.token()).toEqual(token); + expect( + route.accept({ + revision: 7, + phase: "prepare", + assignment: peerAssignment("other_12345678"), + }), + ).toBe("stale"); + }); + + it("returns signaling failure so ViewerPeer can retry its route report", () => { + const route = new MediaRouteTransition(); + const assignment = peerAssignment("host_12345678"); + route.accept({ revision: 9, phase: "active", assignment }); + const send = vi.fn(() => false); + + expect( + reportActivePeerRouteFailure( + route, + "host_12345678", + "connection_12345678", + send, + ), + ).toBe(false); + expect(send).toHaveBeenCalledWith({ + type: "route-failed", + revision: 9, + phase: "active", + connectionId: "connection_12345678", + }); + }); +}); + +describe("HostSfuRoute", () => { + it("reports a publisher prepare failure without requesting refresh", async () => { + const messages: ClientMessage[] = []; + const publisher = createFakePublisher([], "publisher"); + publisher.connect.mockResolvedValue(false); + const route = new HostSfuRoute({ + getStream: () => ({}) as MediaStream, + getProfile: () => QUALITY_PROFILES["720p30"], + reconcileChildren: () => undefined, + send: (message) => { + messages.push(message); + return true; + }, + createPublisher: () => publisher, + }); + + route.accept({ + revision: 1, + phase: "prepare", + assignment: hostAssignment("generation-a"), + }); + await route.acceptConfig(sfuConfig(1)); + + expect(messages).toEqual([ + { + type: "route-failed", + revision: 1, + phase: "prepare", + connectionId: null, + }, + ]); + }); + + it("never overlaps more than two host media edges while changing route kinds", async () => { + const log: string[] = []; + const edgeCounts: number[] = []; + const publishers: Array<{ + connect: ReturnType; + activate: ReturnType; + deactivate: ReturnType; + replaceStream: ReturnType; + updateProfile: ReturnType; + disconnect: ReturnType; + }> = []; + let directEdges = 0; + let sfuEdges = 0; + const record = (event: string) => { + log.push(event); + edgeCounts.push(directEdges + sfuEdges); + }; + const route = new HostSfuRoute({ + getStream: () => ({}) as MediaStream, + getProfile: () => QUALITY_PROFILES["720p30"], + reconcileChildren: (children) => { + directEdges = children.length; + record(`children:${children.join(",")}`); + }, + send: () => true, + createPublisher: () => { + const label = `publisher-${publishers.length + 1}`; + const publisher = { + connect: vi.fn(async () => true), + activate: vi.fn(async () => { + sfuEdges = 1; + record(`${label}:activate`); + return true; + }), + deactivate: vi.fn(async () => { + sfuEdges = 0; + record(`${label}:deactivate`); + return true; + }), + replaceStream: vi.fn(async () => true), + updateProfile: vi.fn(async () => true), + disconnect: vi.fn(async () => record(`${label}:disconnect`)), + }; + publishers.push(publisher); + return publisher; + }, + }); + + route.accept({ + revision: 1, + phase: "active", + assignment: hostAssignment(null, ["direct-a", "direct-b"]), + }); + await vi.waitFor(() => expect(log).toContain("children:direct-a,direct-b")); + + const generationA = hostAssignment("generation-a", ["direct-a"]); + route.accept({ revision: 2, phase: "prepare", assignment: generationA }); + await route.acceptConfig(sfuConfig(2)); + log.length = 0; + route.accept({ revision: 2, phase: "active", assignment: generationA }); + await vi.waitFor(() => expect(publishers[0]?.activate).toHaveBeenCalledOnce()); + expect(log.slice(0, 2)).toEqual([ + "children:direct-a", + "publisher-1:activate", + ]); + + const generationB = hostAssignment("generation-b", ["direct-b"]); + route.accept({ revision: 3, phase: "prepare", assignment: generationB }); + await route.acceptConfig(sfuConfig(3)); + log.length = 0; + route.accept({ revision: 3, phase: "active", assignment: generationB }); + await vi.waitFor(() => expect(publishers[1]?.activate).toHaveBeenCalledOnce()); + expect(log.slice(0, 4)).toEqual([ + "children:direct-b", + "publisher-1:deactivate", + "publisher-1:disconnect", + "publisher-2:activate", + ]); + + log.length = 0; + route.accept({ + revision: 4, + phase: "active", + assignment: hostAssignment(null, ["direct-a", "direct-b"]), + }); + await vi.waitFor(() => + expect(log).toContain("children:direct-a,direct-b"), + ); + expect(log.slice(0, 3)).toEqual([ + "publisher-2:deactivate", + "publisher-2:disconnect", + "children:direct-a,direct-b", + ]); + expect(Math.max(...edgeCounts)).toBeLessThanOrEqual(2); + }); + + it("serializes continuous active revisions while the old publisher retires", async () => { + const publishers: ReturnType[] = []; + const route = new HostSfuRoute({ + getStream: () => ({}) as MediaStream, + getProfile: () => QUALITY_PROFILES["720p30"], + reconcileChildren: () => undefined, + send: () => true, + createPublisher: () => { + const publisher = createFakePublisher( + [], + `publisher-${publishers.length + 1}`, + ); + publishers.push(publisher); + return publisher; + }, + }); + + const generationA = hostAssignment("generation-a", ["direct-a"]); + route.accept({ revision: 1, phase: "prepare", assignment: generationA }); + await route.acceptConfig(sfuConfig(1)); + route.accept({ revision: 1, phase: "active", assignment: generationA }); + await vi.waitFor(() => expect(publishers[0]?.activate).toHaveBeenCalledOnce()); + + let finishOldDeactivate!: () => void; + let oldDeactivateFinished = false; + const oldDeactivateGate = new Promise((resolve) => { + finishOldDeactivate = resolve; + }); + publishers[0]?.deactivate.mockImplementation(async () => { + await oldDeactivateGate; + oldDeactivateFinished = true; + return true; + }); + + const generationB = hostAssignment("generation-b", ["direct-b"]); + route.accept({ revision: 2, phase: "prepare", assignment: generationB }); + await route.acceptConfig(sfuConfig(2)); + route.accept({ revision: 2, phase: "active", assignment: generationB }); + await vi.waitFor(() => expect(publishers[0]?.deactivate).toHaveBeenCalled()); + + const generationC = hostAssignment("generation-c", ["direct-c"]); + route.accept({ revision: 3, phase: "prepare", assignment: generationC }); + await route.acceptConfig(sfuConfig(3)); + route.accept({ revision: 3, phase: "active", assignment: generationC }); + + expect(publishers[2]?.disconnect).not.toHaveBeenCalled(); + expect(publishers[1]?.activate).not.toHaveBeenCalled(); + expect(publishers[2]?.activate).not.toHaveBeenCalled(); + + finishOldDeactivate(); + await vi.waitFor(() => expect(oldDeactivateFinished).toBe(true)); + expect(publishers[2]?.disconnect).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(publishers[2]?.activate).toHaveBeenCalledOnce()); + expect(publishers[1]?.activate).not.toHaveBeenCalled(); + }); + + it("releases active ownership when a published activation becomes stale", async () => { + const streamA = {} as MediaStream; + const streamB = {} as MediaStream; + let currentStream = streamA; + const publishers: ReturnType[] = []; + const route = new HostSfuRoute({ + getStream: () => currentStream, + getProfile: () => QUALITY_PROFILES["720p30"], + reconcileChildren: () => undefined, + send: () => true, + createPublisher: () => { + const publisher = createFakePublisher( + [], + `publisher-${publishers.length + 1}`, + ); + publishers.push(publisher); + return publisher; + }, + }); + + const generationA = hostAssignment("generation-a"); + route.accept({ revision: 1, phase: "prepare", assignment: generationA }); + await route.acceptConfig(sfuConfig(1)); + + let finishReplace!: () => void; + const replaceGate = new Promise((resolve) => { + finishReplace = resolve; + }); + publishers[0]?.activate.mockImplementation(async () => { + currentStream = streamB; + return true; + }); + publishers[0]?.replaceStream.mockImplementation(async () => { + await replaceGate; + return true; + }); + + route.accept({ revision: 1, phase: "active", assignment: generationA }); + await vi.waitFor(() => expect(publishers[0]?.replaceStream).toHaveBeenCalled()); + + const generationB = hostAssignment("generation-b"); + route.accept({ revision: 2, phase: "prepare", assignment: generationB }); + await route.acceptConfig(sfuConfig(2)); + finishReplace(); + await vi.waitFor(() => expect(publishers[0]?.disconnect).toHaveBeenCalled()); + + await expect( + route.updateProfile(QUALITY_PROFILES["1080p30"]), + ).resolves.toBe(true); + expect(publishers[0]?.updateProfile).not.toHaveBeenCalled(); + expect(publishers[1]?.disconnect).not.toHaveBeenCalled(); + + route.accept({ revision: 2, phase: "active", assignment: generationB }); + await vi.waitFor(() => expect(publishers[1]?.activate).toHaveBeenCalledOnce()); + }); + + it("keeps an activating publisher on a duplicate active update", async () => { + const messages: ClientMessage[] = []; + const publisher = createFakePublisher([], "publisher"); + let finishActivate!: () => void; + const activateGate = new Promise((resolve) => { + finishActivate = resolve; + }); + publisher.activate.mockImplementation(async () => { + await activateGate; + return true; + }); + const route = new HostSfuRoute({ + getStream: () => ({}) as MediaStream, + getProfile: () => QUALITY_PROFILES["720p30"], + reconcileChildren: () => undefined, + send: (message) => { + messages.push(message); + return true; + }, + createPublisher: () => publisher, + }); + const assignment = hostAssignment("generation-a"); + + route.accept({ revision: 1, phase: "prepare", assignment }); + await route.acceptConfig(sfuConfig(1)); + route.accept({ revision: 1, phase: "active", assignment }); + await vi.waitFor(() => expect(publisher.activate).toHaveBeenCalledOnce()); + + expect( + route.accept({ revision: 1, phase: "active", assignment }), + ).toBe("duplicate"); + expect(publisher.activate).toHaveBeenCalledOnce(); + expect(publisher.disconnect).not.toHaveBeenCalled(); + + finishActivate(); + await vi.waitFor(() => + expect(messages).toContainEqual({ + type: "route-ready", + revision: 1, + phase: "active", + }), + ); + expect(publisher.disconnect).not.toHaveBeenCalled(); + }); + + it("prepares without media, commits in order, and reuses a publication generation", async () => { + const log: string[] = []; + const messages: ClientMessage[] = []; + const publishers: ReturnType[] = []; + const stream = {} as MediaStream; + const route = new HostSfuRoute({ + getStream: () => stream, + getProfile: () => QUALITY_PROFILES["720p30"], + reconcileChildren: (children) => log.push(`children:${children.join(",")}`), + send: (message) => { + messages.push(message); + return true; + }, + createPublisher: () => { + const publisher = createFakePublisher( + log, + `publisher-${publishers.length + 1}`, + ); + publishers.push(publisher); + return publisher; + }, + }); + + const generationA = hostAssignment("generation-a", ["direct-a"]); + route.accept({ revision: 1, phase: "prepare", assignment: generationA }); + await route.acceptConfig(sfuConfig(1)); + + expect(publishers[0]?.activate).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "route-ready", + revision: 1, + phase: "prepare", + }); + + route.accept({ revision: 1, phase: "active", assignment: generationA }); + await vi.waitFor(() => expect(publishers[0]?.activate).toHaveBeenCalledOnce()); + + const generationB = hostAssignment("generation-b", ["direct-b"]); + route.accept({ revision: 2, phase: "prepare", assignment: generationB }); + await route.acceptConfig(sfuConfig(2)); + expect(publishers[0]?.deactivate).not.toHaveBeenCalled(); + expect(publishers[1]?.activate).not.toHaveBeenCalled(); + + log.length = 0; + route.accept({ revision: 2, phase: "active", assignment: generationB }); + await vi.waitFor(() => expect(publishers[1]?.activate).toHaveBeenCalledOnce()); + expect(log.slice(0, 4)).toEqual([ + "children:direct-b", + "publisher-1:deactivate", + "publisher-1:disconnect", + "publisher-2:activate", + ]); + + route.accept({ + revision: 3, + phase: "active", + assignment: hostAssignment("generation-b", ["direct-c"]), + }); + await vi.waitFor(() => + expect(messages).toContainEqual({ + type: "route-ready", + revision: 3, + phase: "active", + }), + ); + expect(publishers).toHaveLength(2); + expect(publishers[1]?.deactivate).not.toHaveBeenCalled(); + + route.accept({ + revision: 4, + phase: "prepare", + assignment: hostAssignment("generation-c"), + }); + await route.acceptConfig(sfuConfig(4)); + route.accept({ + revision: 5, + phase: "active", + assignment: hostAssignment("generation-b", ["direct-c"]), + }); + await vi.waitFor(() => expect(publishers[2]?.disconnect).toHaveBeenCalled()); + expect(publishers[1]?.deactivate).not.toHaveBeenCalled(); + }); + + it("retires old media before applying a lower authoritative host revision", async () => { + const log: string[] = []; + const publisher = createFakePublisher(log, "publisher"); + const route = new HostSfuRoute({ + getStream: () => ({}) as MediaStream, + getProfile: () => QUALITY_PROFILES["720p30"], + reconcileChildren: (children) => + log.push(`children:${children.join(",")}`), + send: () => true, + createPublisher: () => publisher, + }); + + const oldAssignment = hostAssignment("generation-old"); + route.accept({ revision: 10, phase: "prepare", assignment: oldAssignment }); + await route.acceptConfig(sfuConfig(10)); + route.accept({ revision: 10, phase: "active", assignment: oldAssignment }); + await vi.waitFor(() => expect(publisher.activate).toHaveBeenCalledOnce()); + + let finishDeactivate!: () => void; + const deactivateGate = new Promise((resolve) => { + finishDeactivate = resolve; + }); + publisher.deactivate.mockImplementation(async () => { + log.push("publisher:deactivate"); + await deactivateGate; + return true; + }); + log.length = 0; + + const authoritative = hostAssignment(null, ["new-child-a", "new-child-b"]); + const resync = route.resyncAuthoritative({ + revision: 1, + phase: "active", + assignment: authoritative, + }); + await vi.waitFor(() => expect(publisher.deactivate).toHaveBeenCalledOnce()); + expect(log).toEqual(["children:", "publisher:deactivate"]); + + finishDeactivate(); + await expect(resync).resolves.toBe("accepted"); + expect(log).toEqual([ + "children:", + "publisher:deactivate", + "publisher:disconnect", + "children:new-child-a,new-child-b", + ]); + + await expect( + route.resyncAuthoritative({ + revision: 1, + phase: "active", + assignment: authoritative, + }), + ).resolves.toBe("duplicate"); + expect(publisher.disconnect).toHaveBeenCalledOnce(); + }); +}); + +describe("ViewerSfuRoute", () => { + it("reports a subscriber prepare failure without requesting refresh", async () => { + const messages: ClientMessage[] = []; + const subscriber = createFakeSubscriber( + { onStream: () => undefined, onDisconnected: () => undefined }, + [], + "subscriber", + ); + subscriber.connect.mockResolvedValue(false); + const route = new ViewerSfuRoute({ + activatePeer: () => undefined, + reconcileSfuChildren: () => undefined, + onSfuStream: () => undefined, + send: (message) => { + messages.push(message); + return true; + }, + createSubscriber: () => subscriber, + }); + + route.accept({ + revision: 1, + phase: "prepare", + assignment: sfuAssignment(), + }); + await route.acceptConfig(sfuConfig(1)); + + expect(messages).toEqual([ + { + type: "route-failed", + revision: 1, + phase: "prepare", + connectionId: null, + }, + ]); + }); + + it("buffers an assigned parent offer until SFU fallback retirement completes", async () => { + const log: string[] = []; + const bufferedSignals: string[] = []; + const deliveredSignals: string[] = []; + let preparedParentPeerId: string | null = null; + const subscribers: ReturnType[] = []; + const route = new ViewerSfuRoute({ + activatePeer: (assignment) => { + const peerId = + assignment.upstream.kind === "peer" + ? assignment.upstream.peerId + : "none"; + log.push(`peer:${peerId}`); + if (peerId === preparedParentPeerId) { + deliveredSignals.push(...bufferedSignals.splice(0)); + preparedParentPeerId = null; + } + }, + preparePeer: (assignment) => { + preparedParentPeerId = + assignment?.upstream.kind === "peer" + ? assignment.upstream.peerId + : null; + }, + reconcileSfuChildren: () => undefined, + onSfuStream: () => undefined, + send: () => true, + createSubscriber: (events) => { + const subscriber = createFakeSubscriber( + events, + log, + `subscriber-${subscribers.length + 1}`, + ); + subscribers.push(subscriber); + return subscriber; + }, + }); + const sfu = sfuAssignment(); + + route.accept({ revision: 1, phase: "prepare", assignment: sfu }); + await route.acceptConfig(sfuConfig(1)); + route.accept({ revision: 1, phase: "active", assignment: sfu }); + await vi.waitFor(() => + expect(subscribers[0]?.activate).toHaveBeenCalledOnce(), + ); + subscribers[0]?.events.onStream({} as MediaStream); + + let finishDisconnect!: () => void; + const disconnectGate = new Promise((resolve) => { + finishDisconnect = resolve; + }); + subscribers[0]?.disconnect.mockImplementation(async () => { + log.push("subscriber-1:disconnect"); + await disconnectGate; + }); + + log.length = 0; + route.accept({ + revision: 2, + phase: "active", + assignment: peerAssignment("host-peer"), + }); + expect(preparedParentPeerId).toBe("host-peer"); + bufferedSignals.push("early-offer"); + expect(deliveredSignals).toEqual([]); + await vi.waitFor(() => + expect(subscribers[0]?.disconnect).toHaveBeenCalledOnce(), + ); + expect(log).toEqual([ + "subscriber-1:deactivate", + "subscriber-1:disconnect", + ]); + + finishDisconnect(); + await vi.waitFor(() => expect(log).toContain("peer:host-peer")); + expect(log.slice(0, 3)).toEqual([ + "subscriber-1:deactivate", + "subscriber-1:disconnect", + "peer:host-peer", + ]); + expect(deliveredSignals).toEqual(["early-offer"]); + }); + + it("keeps peer media through prepare and switches on the first video stream callback", async () => { + const log: string[] = []; + const messages: ClientMessage[] = []; + const peerAssignments: ParticipantRouteAssignment[] = []; + const streams: Array<{ + stream: MediaStream; + initialVideoStream: boolean; + assignment: ParticipantRouteAssignment; + }> = []; + const subscribers: ReturnType[] = []; + const route = new ViewerSfuRoute({ + activatePeer: (assignment) => { + peerAssignments.push(assignment); + }, + reconcileSfuChildren: (children) => log.push(`children:${children.join(",")}`), + onSfuStream: (stream, assignment, initialVideoStream) => { + streams.push({ stream, assignment, initialVideoStream }); + }, + send: (message) => { + messages.push(message); + return true; + }, + createSubscriber: (events) => { + const subscriber = createFakeSubscriber( + events, + log, + `subscriber-${subscribers.length + 1}`, + ); + subscribers.push(subscriber); + return subscriber; + }, + }); + + route.accept({ + revision: 1, + phase: "active", + assignment: peerAssignment("host-peer"), + }); + await vi.waitFor(() => expect(peerAssignments).toHaveLength(1)); + const nextAssignment = sfuAssignment(["relay-child"]); + route.accept({ revision: 2, phase: "prepare", assignment: nextAssignment }); + await route.acceptConfig(sfuConfig(2)); + + expect(peerAssignments).toHaveLength(1); + expect(streams).toHaveLength(0); + expect(subscribers[0]?.activate).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "route-ready", + revision: 2, + phase: "prepare", + }); + + route.accept({ revision: 2, phase: "active", assignment: nextAssignment }); + await vi.waitFor(() => + expect(subscribers[0]?.activate).toHaveBeenCalledOnce(), + ); + expect(streams).toHaveLength(0); + expect(messages).not.toContainEqual({ + type: "route-ready", + revision: 2, + phase: "active", + }); + + const stream = {} as MediaStream; + subscribers[0]?.events.onStream(stream); + expect(streams).toEqual([ + { stream, assignment: nextAssignment, initialVideoStream: true }, + ]); + expect(messages).toContainEqual({ + type: "route-ready", + revision: 2, + phase: "active", + }); + + route.accept({ + revision: 3, + phase: "active", + assignment: sfuAssignment(["other-child"]), + }); + expect(subscribers).toHaveLength(1); + expect(subscribers[0]?.deactivate).not.toHaveBeenCalled(); + + route.accept({ + revision: 4, + phase: "prepare", + assignment: sfuAssignment(), + }); + await route.acceptConfig(sfuConfig(4)); + route.accept({ + revision: 5, + phase: "active", + assignment: sfuAssignment(["other-child"]), + }); + await vi.waitFor(() => expect(subscribers[1]?.disconnect).toHaveBeenCalled()); + expect(subscribers[0]?.deactivate).not.toHaveBeenCalled(); + expect(streams).toHaveLength(1); + }); + + it("disconnects a subscriber whose connect completes after rollback", async () => { + let finishConnect!: () => void; + const connectGate = new Promise((resolve) => { + finishConnect = resolve; + }); + const messages: ClientMessage[] = []; + const subscriber = { + connect: vi.fn(async () => { + await connectGate; + return true; + }), + activate: vi.fn(() => true), + deactivate: vi.fn(() => true), + disconnect: vi.fn(async () => undefined), + }; + const route = new ViewerSfuRoute({ + activatePeer: () => undefined, + reconcileSfuChildren: () => undefined, + onSfuStream: () => undefined, + send: (message) => { + messages.push(message); + return true; + }, + createSubscriber: () => subscriber, + }); + + route.accept({ + revision: 10, + phase: "prepare", + assignment: sfuAssignment(), + }); + const connecting = route.acceptConfig(sfuConfig(10)); + route.accept({ + revision: 11, + phase: "active", + assignment: peerAssignment("host-peer"), + }); + finishConnect(); + await connecting; + + expect(subscriber.activate).not.toHaveBeenCalled(); + expect(subscriber.disconnect).toHaveBeenCalled(); + expect(messages).not.toContainEqual({ + type: "route-ready", + revision: 10, + phase: "prepare", + }); + }); + + it("refreshes once after terminal loss and reports a failed rebuild", async () => { + const messages: ClientMessage[] = []; + const subscribers: ReturnType[] = []; + const route = new ViewerSfuRoute({ + activatePeer: () => undefined, + reconcileSfuChildren: () => undefined, + onSfuStream: () => undefined, + send: (message) => { + messages.push(message); + return true; + }, + createSubscriber: (events) => { + const subscriber = createFakeSubscriber( + events, + [], + `subscriber-${subscribers.length + 1}`, + ); + if (subscribers.length === 1) { + subscriber.connect.mockResolvedValue(false); + } + subscribers.push(subscriber); + return subscriber; + }, + }); + const assignment = sfuAssignment(); + + route.accept({ revision: 20, phase: "prepare", assignment }); + await route.acceptConfig(sfuConfig(20)); + route.accept({ revision: 20, phase: "active", assignment }); + await vi.waitFor(() => + expect(subscribers[0]?.activate).toHaveBeenCalledOnce(), + ); + subscribers[0]?.events.onStream({} as MediaStream); + messages.length = 0; + + subscribers[0]?.events.onDisconnected(); + expect(messages).toEqual([{ type: "refresh-sfu", revision: 20 }]); + + await route.acceptConfig(sfuConfig(20)); + expect(messages).toEqual([ + { type: "refresh-sfu", revision: 20 }, + { + type: "route-failed", + revision: 20, + phase: "active", + connectionId: null, + }, + ]); + }); + + it("retires old media before applying a lower authoritative viewer revision", async () => { + const log: string[] = []; + const bufferedSignals: string[] = []; + const deliveredSignals: string[] = []; + let preparedParentPeerId: string | null = null; + const subscribers: ReturnType[] = []; + const route = new ViewerSfuRoute({ + activatePeer: (assignment) => { + const upstream = assignment.upstream; + log.push( + `peer:${upstream.kind === "peer" ? upstream.peerId : upstream.kind}`, + ); + if ( + upstream.kind === "peer" && + upstream.peerId === preparedParentPeerId + ) { + deliveredSignals.push(...bufferedSignals.splice(0)); + preparedParentPeerId = null; + } + }, + preparePeer: (assignment) => { + preparedParentPeerId = + assignment?.upstream.kind === "peer" + ? assignment.upstream.peerId + : null; + }, + resetMedia: () => log.push("reset-media"), + reconcileSfuChildren: () => undefined, + onSfuStream: () => undefined, + send: () => true, + createSubscriber: (events) => { + const subscriber = createFakeSubscriber( + events, + log, + `subscriber-${subscribers.length + 1}`, + ); + subscribers.push(subscriber); + return subscriber; + }, + }); + + route.accept({ revision: 10, phase: "prepare", assignment: sfuAssignment() }); + await route.acceptConfig(sfuConfig(10)); + route.accept({ revision: 10, phase: "active", assignment: sfuAssignment() }); + await vi.waitFor(() => + expect(subscribers[0]?.activate).toHaveBeenCalledOnce(), + ); + subscribers[0]?.events.onStream({} as MediaStream); + log.length = 0; + + let finishDisconnect!: () => void; + const disconnectGate = new Promise((resolve) => { + finishDisconnect = resolve; + }); + subscribers[0]?.disconnect.mockImplementation(async () => { + log.push("subscriber-1:disconnect"); + await disconnectGate; + }); + + const authoritative = peerAssignment("new-host-peer"); + const resync = route.resyncAuthoritative({ + revision: 1, + phase: "active", + assignment: authoritative, + }); + expect(preparedParentPeerId).toBe("new-host-peer"); + bufferedSignals.push("early-resync-offer"); + expect(deliveredSignals).toEqual([]); + await vi.waitFor(() => + expect(subscribers[0]?.disconnect).toHaveBeenCalledOnce(), + ); + expect(log).toEqual([ + "reset-media", + "subscriber-1:deactivate", + "subscriber-1:disconnect", + ]); + + finishDisconnect(); + await expect(resync).resolves.toBe("accepted"); + expect(log).toEqual([ + "reset-media", + "subscriber-1:deactivate", + "subscriber-1:disconnect", + "peer:new-host-peer", + ]); + expect(deliveredSignals).toEqual(["early-resync-offer"]); + + await expect( + route.resyncAuthoritative({ + revision: 1, + phase: "active", + assignment: authoritative, + }), + ).resolves.toBe("duplicate"); + expect(subscribers[0]?.disconnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index 81205ee..ee8a0f0 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -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", () => { diff --git a/tests/server-http.test.ts b/tests/server-http.test.ts index 80636a3..8980ead 100644 --- a/tests/server-http.test.ts +++ b/tests/server-http.test.ts @@ -52,7 +52,10 @@ function testConfig(overrides: Partial = {}): ServerConfig { async function start( config = testConfig(), - options: Pick = {}, + options: Pick< + CreateServerOptions, + "now" | "accessSessionTtlSeconds" | "sfuTokenIssuer" + > = {}, ): Promise { 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(), diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index 0604b0b..b2ae694 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -1,4 +1,5 @@ import { connect } from "node:net"; +import { createServer } from "node:http"; import { afterEach, describe, expect, it } from "vitest"; import WebSocket from "ws"; @@ -16,6 +17,8 @@ import { import type { ServerConfig } from "../src/server/config.ts"; import { RoomDatabase } from "../src/server/room-database.ts"; import { RoomStore, type CreatedRoom } from "../src/server/room-store.ts"; +import { SignalingServer } from "../src/server/signaling.ts"; +import type { SfuTokenIssuer } from "../src/server/livekit-token.ts"; const allowedOrigin = "http://allowed.test"; let runningServer: ScreenerServer | undefined; @@ -27,6 +30,7 @@ afterEach(async () => { class MessageInbox { private readonly messages: ServerMessage[] = []; + private readonly ignoredTypes = new Set(); private readonly waiters: Array<{ type: ServerMessage["type"]; resolve: (message: ServerMessage) => void; @@ -37,6 +41,9 @@ class MessageInbox { constructor(socket: WebSocket) { socket.on("message", (data) => { const message = decodeServerMessage(data.toString()); + if (this.ignoredTypes.has(message.type)) { + return; + } const waiterIndex = this.waiters.findIndex((waiter) => waiter.type === message.type); if (waiterIndex === -1) { this.messages.push(message); @@ -85,6 +92,15 @@ class MessageInbox { throw new Error(`Unexpected message: ${this.messages[0].type}`); } } + + ignore(type: ServerMessage["type"]): void { + this.ignoredTypes.add(type); + for (let index = this.messages.length - 1; index >= 0; index -= 1) { + if (this.messages[index]?.type === type) { + this.messages.splice(index, 1); + } + } + } } interface TestClient { @@ -161,6 +177,77 @@ async function startHarness( }; } +async function startSfuHarness(options: { + tokenIssuer: SfuTokenIssuer; + prepareTimeoutMs?: number; + maxRoots?: number; + viewerDisconnectGraceMs?: number; +}): Promise { + const roomStore = new RoomStore({ + ttlMs: 14_400_000, + maxRooms: 10, + maxViewersPerRoom: 8, + }); + const room = roomStore.createRoom(); + const httpServer = createServer((_request, response) => { + response.statusCode = 404; + response.end(); + }); + const signaling = new SignalingServer({ + server: httpServer, + roomStore, + peerAssistedMedia: true, + sfuFallback: { + url: "wss://sfu.example.test", + tokenIssuer: options.tokenIssuer, + maxRoots: options.maxRoots ?? 2, + prepareTimeoutMs: options.prepareTimeoutMs, + }, + ice: { + stunUrls: [], + turnUrls: [], + credentialTtlSeconds: 3_600, + }, + allowedOrigins: new Set([allowedOrigin]), + authorizeUpgrade: () => true, + authenticationTimeoutMs: 500, + viewerDisconnectGraceMs: options.viewerDisconnectGraceMs ?? 50, + heartbeatIntervalMs: 60_000, + cleanupIntervalMs: 60_000, + }); + runningServer = { + httpServer, + roomStore, + listen: (port = 0, host = "127.0.0.1") => + new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen(port, host, () => { + httpServer.off("error", reject); + const address = httpServer.address(); + if (!address || typeof address === "string") { + reject(new Error("Test HTTP server has no TCP address")); + return; + } + resolve(address.port); + }); + }), + async close() { + await signaling.close(); + await new Promise((resolve, reject) => { + httpServer.close((error) => (error ? reject(error) : resolve())); + }); + roomStore.close(); + }, + }; + const port = await runningServer.listen(); + return { + baseUrl: `http://127.0.0.1:${port}`, + webSocketUrl: `ws://127.0.0.1:${port}/signal`, + roomStore, + room, + }; +} + async function openClient( webSocketUrl: string, cookie?: string, @@ -229,6 +316,8 @@ async function authenticate( room: CreatedRoom, role: Role, clientId: string, + relayCapacity: 0 | 1 | null = 1, + shareGeneration?: string, ) { client.socket.send( JSON.stringify( @@ -239,6 +328,7 @@ async function authenticate( role, token: room.hostToken, clientId, + ...(shareGeneration ? { shareGeneration } : {}), } : { type: "authenticate", @@ -248,7 +338,20 @@ async function authenticate( }, ), ); - return client.inbox.next("authenticated"); + const authenticated = await client.inbox.next("authenticated"); + if ( + role === "viewer" && + relayCapacity !== null && + "mediaMode" in authenticated + ) { + client.socket.send( + JSON.stringify({ + type: "relay-capacity", + downstreamEdges: relayCapacity, + }), + ); + } + return authenticated; } function peerAssisted( @@ -260,6 +363,219 @@ function peerAssisted( return message; } +async function nextActiveRouteAfter( + client: TestClient, + revision: number, +) { + while (true) { + const message = await client.inbox.next("route-update"); + if (message.phase === "active" && message.revision > revision) { + return message; + } + } +} + +async function nextPreparedRoute(client: TestClient) { + while (true) { + const message = await client.inbox.next("route-update"); + if (message.phase === "prepare") { + return message; + } + } +} + +async function nextActiveRouteRevision(client: TestClient, revision: number) { + while (true) { + const message = await client.inbox.next("route-update"); + if (message.phase === "active" && message.revision === revision) { + return message; + } + } +} + +async function prepareFallbackForTwoViewers( + webSocketUrl: string, + room: CreatedRoom, + prefix: string, + shareGeneration?: string, +) { + const host = await openClient(webSocketUrl); + const hostAuth = peerAssisted( + await authenticate( + host, + room, + "host", + `${prefix}-host-client`, + 1, + shareGeneration, + ), + ); + const failedViewer = await openClient(webSocketUrl); + const failedAuth = peerAssisted( + await authenticate( + failedViewer, + room, + "viewer", + `${prefix}-failed-client`, + ), + ); + const rootViewer = await openClient(webSocketUrl); + const rootAuth = peerAssisted( + await authenticate( + rootViewer, + room, + "viewer", + `${prefix}-root-client`, + ), + ); + const directRoute = await nextActiveRouteAfter( + failedViewer, + failedAuth.routeRevision, + ); + host.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: failedAuth.peerId, + payload: { + kind: "description", + connectionId: `${prefix}-direct-connection`, + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await failedViewer.inbox.next("signal"); + failedViewer.socket.send( + JSON.stringify({ + type: "route-failed", + revision: directRoute.revision, + phase: "active", + connectionId: `${prefix}-direct-connection`, + }), + ); + const reparented = await nextActiveRouteAfter( + failedViewer, + directRoute.revision, + ); + rootViewer.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: failedAuth.peerId, + payload: { + kind: "description", + connectionId: `${prefix}-deep-connection`, + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await failedViewer.inbox.next("signal"); + failedViewer.socket.send( + JSON.stringify({ + type: "route-failed", + revision: reparented.revision, + phase: "active", + connectionId: `${prefix}-deep-connection`, + }), + ); + const hostPrepare = await nextPreparedRoute(host); + const rootPrepare = await nextPreparedRoute(failedViewer); + await host.inbox.next("sfu-config"); + await failedViewer.inbox.next("sfu-config"); + return { + host, + hostAuth, + failedViewer, + failedAuth, + rootViewer, + rootAuth, + hostPrepare, + rootPrepare, + }; +} + +async function exhaustDeepViewerPeerRoutes( + webSocketUrl: string, + room: CreatedRoom, + prefix: string, +) { + const host = await openClient(webSocketUrl); + const hostAuth = peerAssisted( + await authenticate(host, room, "host", `${prefix}-host`), + ); + const firstRoot = await openClient(webSocketUrl); + const firstRootAuth = peerAssisted( + await authenticate(firstRoot, room, "viewer", `${prefix}-root-a`), + ); + const secondRoot = await openClient(webSocketUrl); + const secondRootAuth = peerAssisted( + await authenticate(secondRoot, room, "viewer", `${prefix}-root-b`), + ); + const failedViewer = await openClient(webSocketUrl); + const failedAuth = peerAssisted( + await authenticate(failedViewer, room, "viewer", `${prefix}-failed`), + ); + + firstRoot.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: failedAuth.peerId, + payload: { + kind: "description", + connectionId: `${prefix}-edge-a`, + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await failedViewer.inbox.next("signal"); + failedViewer.socket.send( + JSON.stringify({ + type: "route-failed", + revision: failedAuth.routeRevision, + phase: "active", + connectionId: `${prefix}-edge-a`, + }), + ); + const reparented = await nextActiveRouteAfter( + failedViewer, + failedAuth.routeRevision, + ); + expect(reparented.assignment.upstream).toEqual({ + kind: "peer", + peerId: secondRootAuth.peerId, + }); + + secondRoot.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: failedAuth.peerId, + payload: { + kind: "description", + connectionId: `${prefix}-edge-b`, + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await failedViewer.inbox.next("signal"); + failedViewer.socket.send( + JSON.stringify({ + type: "route-failed", + revision: reparented.revision, + phase: "active", + connectionId: `${prefix}-edge-b`, + }), + ); + + return { + host, + hostAuth, + firstRoot, + firstRootAuth, + secondRoot, + secondRootAuth, + failedViewer, + failedAuth, + }; +} + async function closeClient(client: TestClient): Promise { if (client.socket.readyState === WebSocket.CLOSED) { return; @@ -303,6 +619,7 @@ describe("WebSocket signaling", () => { ); expect(viewerAuth.hostOnline).toBe(false); expect("mediaMode" in viewerAuth).toBe(false); + expect("routeRevision" in viewerAuth).toBe(false); const host = await openClient(harness.webSocketUrl); await authenticate(host, harness.room, "host", "host-client-stable"); @@ -313,6 +630,35 @@ describe("WebSocket signaling", () => { }); }); + it("advances a provisional peer route when the host arrives after viewers", async () => { + const harness = await startHarness({ peerAssistedMedia: true }); + const viewer = await openClient(harness.webSocketUrl); + const viewerAuth = peerAssisted( + await authenticate( + viewer, + harness.room, + "viewer", + "early-relay-viewer", + ), + ); + expect(viewerAuth).toMatchObject({ + routeRevision: 0, + routeAssignment: { upstream: { kind: "none" } }, + }); + + const host = await openClient(harness.webSocketUrl); + const hostAuth = peerAssisted( + await authenticate(host, harness.room, "host", "early-relay-host"), + ); + expect(hostAuth.routeRevision).toBeGreaterThan(viewerAuth.routeRevision); + expect(await nextActiveRouteAfter(viewer, viewerAuth.routeRevision)).toMatchObject({ + revision: hostAuth.routeRevision, + assignment: { + upstream: { kind: "peer", peerId: hostAuth.peerId }, + }, + }); + }); + it("authenticates a persistent room without a room expiry", async () => { const harness = await startHarness({ persistent: true }); expect(harness.room).toMatchObject({ roomId: "1", expiresAt: null }); @@ -376,6 +722,79 @@ describe("WebSocket signaling", () => { await lateViewer.inbox.expectNone(30); }); + it("uses only explicitly advertised viewer relay slots without moving healthy edges", async () => { + const harness = await startHarness({ peerAssistedMedia: true }); + const host = await openClient(harness.webSocketUrl); + const hostAuth = peerAssisted( + await authenticate(host, harness.room, "host", "capacity-host"), + ); + const first = await openClient(harness.webSocketUrl); + const firstAuth = peerAssisted( + await authenticate( + first, + harness.room, + "viewer", + "capacity-first", + null, + ), + ); + const second = await openClient(harness.webSocketUrl); + const secondAuth = peerAssisted( + await authenticate( + second, + harness.room, + "viewer", + "capacity-second", + null, + ), + ); + const pending = await openClient(harness.webSocketUrl); + const pendingAuth = peerAssisted( + await authenticate( + pending, + harness.room, + "viewer", + "capacity-pending", + null, + ), + ); + + expect(firstAuth.routeAssignment.upstream).toEqual({ + kind: "peer", + peerId: hostAuth.peerId, + }); + expect(secondAuth.routeAssignment.upstream).toEqual({ + kind: "peer", + peerId: hostAuth.peerId, + }); + expect(pendingAuth.routeAssignment.upstream).toEqual({ kind: "none" }); + + first.socket.send( + JSON.stringify({ type: "relay-capacity", downstreamEdges: 1 }), + ); + const pendingRoute = await nextActiveRouteAfter( + pending, + pendingAuth.routeRevision, + ); + expect(pendingRoute.assignment.upstream).toEqual({ + kind: "peer", + peerId: firstAuth.peerId, + }); + const secondRoute = await nextActiveRouteAfter( + second, + secondAuth.routeRevision, + ); + expect(secondRoute.assignment.upstream).toEqual({ + kind: "peer", + peerId: hostAuth.peerId, + }); + + host.socket.send( + JSON.stringify({ type: "relay-capacity", downstreamEdges: 1 }), + ); + expect((await host.inbox.next("error")).code).toBe("FORBIDDEN"); + }); + it("keeps the P2P authenticated wire unchanged and forbids profile updates", async () => { const harness = await startHarness(); const host = await openClient(harness.webSocketUrl); @@ -413,6 +832,10 @@ describe("WebSocket signaling", () => { }), ); expect((await viewer.inbox.next("error")).code).toBe("FORBIDDEN"); + viewer.socket.send( + JSON.stringify({ type: "relay-capacity", downstreamEdges: 1 }), + ); + expect((await viewer.inbox.next("error")).code).toBe("FORBIDDEN"); await viewer.inbox.expectNone(30); }); @@ -501,6 +924,14 @@ describe("WebSocket signaling", () => { parentPeerId: null, childPeerIds: [], }); + expect(hostAuth).toMatchObject({ + routeRevision: 0, + routeAssignment: { + upstream: { kind: "none" }, + childPeerIds: [], + sfuPublicationGeneration: null, + }, + }); const firstViewer = await openClient(harness.webSocketUrl); const firstAuth = peerAssisted( @@ -512,6 +943,10 @@ describe("WebSocket signaling", () => { ), ); expect(firstAuth.mediaAssignment.parentPeerId).toBe(hostAuth.peerId); + expect(firstAuth.routeAssignment.upstream).toEqual({ + kind: "peer", + peerId: hostAuth.peerId, + }); expect((await host.inbox.next("media-assignment")).mediaAssignment).toEqual({ parentPeerId: null, childPeerIds: [firstAuth.peerId], @@ -671,6 +1106,1106 @@ describe("WebSocket signaling", () => { await foreignViewer.inbox.expectNone(30); }); + it("reparents failed peer edges before committing a bounded SFU fallback", async () => { + const issued: Array< + Parameters[0] + > = []; + const harness = await startSfuHarness({ + tokenIssuer: { + async issueToken(request) { + issued.push(request); + return `token-${request.peerId}`; + }, + }, + }); + const host = await openClient(harness.webSocketUrl); + const hostAuth = peerAssisted( + await authenticate(host, harness.room, "host", "hybrid-host"), + ); + const firstViewer = await openClient(harness.webSocketUrl); + const firstAuth = peerAssisted( + await authenticate( + firstViewer, + harness.room, + "viewer", + "hybrid-viewer-a", + ), + ); + const secondViewer = await openClient(harness.webSocketUrl); + const secondAuth = peerAssisted( + await authenticate( + secondViewer, + harness.room, + "viewer", + "hybrid-viewer-b", + ), + ); + const firstBeforeFailure = await nextActiveRouteAfter( + firstViewer, + firstAuth.routeRevision, + ); + + host.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: firstAuth.peerId, + payload: { + kind: "description", + connectionId: "hybrid-direct-connection", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await firstViewer.inbox.next("signal"); + firstViewer.socket.send( + JSON.stringify({ + type: "route-failed", + revision: firstBeforeFailure.revision, + phase: "active", + connectionId: "hybrid-direct-connection", + }), + ); + + const firstReparented = await nextActiveRouteAfter( + firstViewer, + firstBeforeFailure.revision, + ); + expect(firstReparented.assignment.upstream).toEqual({ + kind: "peer", + peerId: secondAuth.peerId, + }); + const hostAfterReparent = await nextActiveRouteAfter( + host, + firstBeforeFailure.revision, + ); + expect(hostAfterReparent.assignment).toMatchObject({ + childPeerIds: [secondAuth.peerId], + sfuPublicationGeneration: null, + }); + + secondViewer.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: firstAuth.peerId, + payload: { + kind: "description", + connectionId: "hybrid-deep-connection", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await firstViewer.inbox.next("signal"); + firstViewer.socket.send( + JSON.stringify({ + type: "route-failed", + revision: firstReparented.revision, + phase: "active", + connectionId: "hybrid-deep-connection", + }), + ); + + const hostPrepare = await nextPreparedRoute(host); + const rootPrepare = await nextPreparedRoute(firstViewer); + expect(rootPrepare.revision).toBe(hostPrepare.revision); + expect(hostPrepare.assignment).toMatchObject({ + upstream: { kind: "none" }, + childPeerIds: [secondAuth.peerId], + }); + expect(rootPrepare.assignment).toMatchObject({ + upstream: { kind: "sfu" }, + childPeerIds: [], + }); + expect(await host.inbox.next("sfu-config")).toMatchObject({ + revision: hostPrepare.revision, + url: "wss://sfu.example.test", + }); + expect(await firstViewer.inbox.next("sfu-config")).toMatchObject({ + revision: hostPrepare.revision, + }); + await expect(secondViewer.inbox.next("sfu-config", 30)).rejects.toThrow( + "Timed out", + ); + + secondViewer.socket.send( + JSON.stringify({ + type: "route-ready", + revision: hostPrepare.revision, + phase: "prepare", + }), + ); + expect((await secondViewer.inbox.next("error")).code).toBe("FORBIDDEN"); + host.socket.send( + JSON.stringify({ + type: "route-ready", + revision: hostPrepare.revision, + phase: "prepare", + }), + ); + firstViewer.socket.send( + JSON.stringify({ + type: "route-ready", + revision: hostPrepare.revision, + phase: "prepare", + }), + ); + + const hostActive = await nextActiveRouteAfter( + host, + hostPrepare.revision - 1, + ); + const rootActive = await nextActiveRouteAfter( + firstViewer, + hostPrepare.revision - 1, + ); + expect(hostActive.revision).toBe(hostPrepare.revision); + expect(hostActive.assignment.childPeerIds).toEqual([secondAuth.peerId]); + expect(hostActive.assignment.sfuPublicationGeneration).toBeTruthy(); + expect(rootActive.assignment.upstream).toEqual({ kind: "sfu" }); + expect(new Set(issued.map(({ peerId }) => peerId))).toEqual( + new Set([hostAuth.peerId, firstAuth.peerId]), + ); + expect(new Set(issued.map(({ publicationGeneration }) => publicationGeneration)).size).toBe(1); + + host.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: firstAuth.peerId, + payload: { + kind: "candidate", + connectionId: "no-longer-direct", + candidate: null, + }, + }), + ); + expect((await host.inbox.next("error")).code).toBe("FORBIDDEN"); + + await closeClient(firstViewer); + const reconnectedRoot = await openClient(harness.webSocketUrl); + const reconnectedRootAuth = peerAssisted( + await authenticate( + reconnectedRoot, + harness.room, + "viewer", + "hybrid-viewer-a", + ), + ); + expect(reconnectedRootAuth).toMatchObject({ + peerId: firstAuth.peerId, + routeRevision: hostPrepare.revision, + routeAssignment: { upstream: { kind: "sfu" } }, + mediaAssignment: { parentPeerId: null }, + }); + expect(await reconnectedRoot.inbox.next("sfu-config")).toMatchObject({ + revision: hostPrepare.revision, + }); + + const issuedBeforeRefresh = issued.length; + for (let requestIndex = 0; requestIndex < 2; requestIndex += 1) { + reconnectedRoot.socket.send( + JSON.stringify({ + type: "refresh-sfu", + revision: hostPrepare.revision, + }), + ); + } + expect(await reconnectedRoot.inbox.next("sfu-config")).toMatchObject({ + revision: hostPrepare.revision, + }); + expect((await reconnectedRoot.inbox.next("error")).code).toBe("FORBIDDEN"); + expect(issued).toHaveLength(issuedBeforeRefresh + 1); + const issuedBeforeFailback = issued.length; + reconnectedRoot.socket.send( + JSON.stringify({ + type: "route-failed", + revision: hostPrepare.revision, + phase: "active", + connectionId: null, + }), + ); + const peerBaseline = await nextActiveRouteAfter( + reconnectedRoot, + hostPrepare.revision, + ); + const hostPeerBaseline = await nextActiveRouteAfter( + host, + hostPrepare.revision, + ); + expect(peerBaseline.assignment.upstream).toEqual({ + kind: "peer", + peerId: secondAuth.peerId, + }); + expect(hostPeerBaseline.assignment).toMatchObject({ + childPeerIds: [secondAuth.peerId], + sfuPublicationGeneration: null, + }); + + secondViewer.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: firstAuth.peerId, + payload: { + kind: "description", + connectionId: "after-sfu-root-connection", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await reconnectedRoot.inbox.next("signal"); + reconnectedRoot.socket.send( + JSON.stringify({ + type: "route-failed", + revision: peerBaseline.revision, + phase: "active", + connectionId: "after-sfu-root-connection", + }), + ); + const directAgain = await nextActiveRouteAfter( + reconnectedRoot, + peerBaseline.revision, + ); + expect(directAgain.assignment.upstream).toEqual({ + kind: "peer", + peerId: hostAuth.peerId, + }); + host.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: firstAuth.peerId, + payload: { + kind: "description", + connectionId: "after-sfu-host-connection", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await reconnectedRoot.inbox.next("signal"); + reconnectedRoot.socket.send( + JSON.stringify({ + type: "route-failed", + revision: directAgain.revision, + phase: "active", + connectionId: "after-sfu-host-connection", + }), + ); + expect((await reconnectedRoot.inbox.next("error")).code).toBe("PEER_NOT_FOUND"); + expect(issued).toHaveLength(issuedBeforeFailback); + + host.socket.send(JSON.stringify({ type: "stop-sharing" })); + await reconnectedRoot.inbox.next("sharing-stopped"); + const resumedHost = await openClient(harness.webSocketUrl); + const resumedHostAuth = peerAssisted( + await authenticate(resumedHost, harness.room, "host", "hybrid-host"), + ); + expect(resumedHostAuth.routeAssignment).toMatchObject({ + upstream: { kind: "none" }, + sfuPublicationGeneration: null, + }); + expect(resumedHostAuth.routeAssignment.childPeerIds).toHaveLength(2); + + const resumedFirstRoute = await nextActiveRouteRevision( + reconnectedRoot, + resumedHostAuth.routeRevision, + ); + resumedHost.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: firstAuth.peerId, + payload: { + kind: "description", + connectionId: "resumed-direct-connection", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await reconnectedRoot.inbox.next("signal"); + reconnectedRoot.socket.send( + JSON.stringify({ + type: "route-failed", + revision: resumedFirstRoute.revision, + phase: "active", + connectionId: "resumed-direct-connection", + }), + ); + const resumedReparent = await nextActiveRouteAfter( + reconnectedRoot, + resumedFirstRoute.revision, + ); + secondViewer.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: firstAuth.peerId, + payload: { + kind: "description", + connectionId: "resumed-deep-connection", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await reconnectedRoot.inbox.next("signal"); + reconnectedRoot.socket.send( + JSON.stringify({ + type: "route-failed", + revision: resumedReparent.revision, + phase: "active", + connectionId: "resumed-deep-connection", + }), + ); + const resumedPrepare = await nextPreparedRoute(resumedHost); + expect(await resumedHost.inbox.next("sfu-config")).toMatchObject({ + revision: resumedPrepare.revision, + }); + expect(issued.length).toBeGreaterThan(issuedBeforeFailback); + }); + + it("moves both a failed deep viewer and its host branch within two SFU roots", async () => { + const issued: Array[0]> = []; + const harness = await startSfuHarness({ + tokenIssuer: { + async issueToken(request) { + issued.push(request); + return `token-${request.peerId}`; + }, + }, + }); + const peers = await exhaustDeepViewerPeerRoutes( + harness.webSocketUrl, + harness.room, + "deep-budget", + ); + + const hostPrepare = await nextPreparedRoute(peers.host); + const branchPrepare = await nextPreparedRoute(peers.secondRoot); + const failedPrepare = await nextPreparedRoute(peers.failedViewer); + expect(branchPrepare.revision).toBe(hostPrepare.revision); + expect(failedPrepare.revision).toBe(hostPrepare.revision); + expect(hostPrepare.assignment.childPeerIds).toEqual([ + peers.firstRootAuth.peerId, + ]); + expect(branchPrepare.assignment.upstream).toEqual({ kind: "sfu" }); + expect(failedPrepare.assignment.upstream).toEqual({ kind: "sfu" }); + expect( + new Set(issued.map(({ peerId }) => peerId)), + ).toEqual( + new Set([ + peers.hostAuth.peerId, + peers.secondRootAuth.peerId, + peers.failedAuth.peerId, + ]), + ); + }); + + it("fails a deep SFU fallback cleanly when its root budget is one", async () => { + const harness = await startSfuHarness({ + tokenIssuer: { + async issueToken(request) { + return `token-${request.peerId}`; + }, + }, + maxRoots: 1, + }); + const peers = await exhaustDeepViewerPeerRoutes( + harness.webSocketUrl, + harness.room, + "deep-limited", + ); + + expect((await peers.failedViewer.inbox.next("error")).code).toBe( + "PEER_NOT_FOUND", + ); + await expect(nextPreparedRoute(peers.host)).rejects.toThrow("Timed out"); + expect(peers.host.socket.readyState).toBe(WebSocket.OPEN); + }); + + it("admits a leaf with no peer slot through a selective SFU route", async () => { + const harness = await startSfuHarness({ + tokenIssuer: { + async issueToken(request) { + return `token-${request.peerId}`; + }, + }, + }); + const host = await openClient(harness.webSocketUrl); + await authenticate(host, harness.room, "host", "admission-host"); + const firstLeaf = await openClient(harness.webSocketUrl); + const firstAuth = peerAssisted( + await authenticate( + firstLeaf, + harness.room, + "viewer", + "admission-leaf-a", + 0, + ), + ); + const secondLeaf = await openClient(harness.webSocketUrl); + const secondAuth = peerAssisted( + await authenticate( + secondLeaf, + harness.room, + "viewer", + "admission-leaf-b", + 0, + ), + ); + const pendingLeaf = await openClient(harness.webSocketUrl); + const pendingAuth = peerAssisted( + await authenticate( + pendingLeaf, + harness.room, + "viewer", + "admission-leaf-c", + 0, + ), + ); + expect(pendingAuth.routeAssignment.upstream).toEqual({ kind: "none" }); + + const hostPrepare = await nextPreparedRoute(host); + const budgetPrepare = await nextPreparedRoute(firstLeaf); + const pendingPrepare = await nextPreparedRoute(pendingLeaf); + expect(hostPrepare.assignment.childPeerIds).toEqual([secondAuth.peerId]); + expect(budgetPrepare.assignment.upstream).toEqual({ kind: "sfu" }); + expect(pendingPrepare.assignment.upstream).toEqual({ kind: "sfu" }); + expect(await firstLeaf.inbox.next("sfu-config")).toMatchObject({ + token: `token-${firstAuth.peerId}`, + }); + expect(await pendingLeaf.inbox.next("sfu-config")).toMatchObject({ + token: `token-${pendingAuth.peerId}`, + }); + }); + + it("uses a free direct slot beside an active SFU publication", async () => { + const harness = await startSfuHarness({ + tokenIssuer: { + async issueToken(request) { + return `token-${request.peerId}`; + }, + }, + }); + const host = await openClient(harness.webSocketUrl); + const hostAuth = peerAssisted( + await authenticate(host, harness.room, "host", "active-slot-host"), + ); + const root = await openClient(harness.webSocketUrl); + const rootAuth = peerAssisted( + await authenticate(root, harness.room, "viewer", "active-slot-root", 0), + ); + host.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: rootAuth.peerId, + payload: { + kind: "description", + connectionId: "active-slot-failed-edge", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await root.inbox.next("signal"); + root.socket.send( + JSON.stringify({ + type: "route-failed", + revision: rootAuth.routeRevision, + phase: "active", + connectionId: "active-slot-failed-edge", + }), + ); + const hostPrepare = await nextPreparedRoute(host); + const rootPrepare = await nextPreparedRoute(root); + await host.inbox.next("sfu-config"); + await root.inbox.next("sfu-config"); + host.socket.send( + JSON.stringify({ + type: "route-ready", + revision: hostPrepare.revision, + phase: "prepare", + }), + ); + root.socket.send( + JSON.stringify({ + type: "route-ready", + revision: rootPrepare.revision, + phase: "prepare", + }), + ); + await nextActiveRouteAfter(host, hostPrepare.revision - 1); + + const directViewer = await openClient(harness.webSocketUrl); + const directAuth = peerAssisted( + await authenticate( + directViewer, + harness.room, + "viewer", + "active-slot-direct", + 0, + ), + ); + expect(directAuth.routeAssignment.upstream).toEqual({ + kind: "peer", + peerId: hostAuth.peerId, + }); + expect(directAuth.routeAssignment.sfuPublicationGeneration).toBeNull(); + }); + + it("retires an SFU publication when its root allowlist shrinks", async () => { + const issued: Array[0]> = []; + const harness = await startSfuHarness({ + tokenIssuer: { + async issueToken(request) { + issued.push(request); + return `token-${request.peerId}`; + }, + }, + }); + const peers = await exhaustDeepViewerPeerRoutes( + harness.webSocketUrl, + harness.room, + "shrink-roots", + ); + const hostPrepare = await nextPreparedRoute(peers.host); + const branchPrepare = await nextPreparedRoute(peers.secondRoot); + const failedPrepare = await nextPreparedRoute(peers.failedViewer); + await peers.host.inbox.next("sfu-config"); + await peers.secondRoot.inbox.next("sfu-config"); + await peers.failedViewer.inbox.next("sfu-config"); + for (const [client, revision] of [ + [peers.host, hostPrepare.revision], + [peers.secondRoot, branchPrepare.revision], + [peers.failedViewer, failedPrepare.revision], + ] as const) { + client.socket.send( + JSON.stringify({ type: "route-ready", revision, phase: "prepare" }), + ); + } + const hostActive = await nextActiveRouteAfter( + peers.host, + hostPrepare.revision - 1, + ); + await nextActiveRouteAfter( + peers.secondRoot, + branchPrepare.revision - 1, + ); + expect(hostActive.assignment.sfuPublicationGeneration).toBeTruthy(); + expect(issued).toHaveLength(3); + + await closeClient(peers.failedViewer); + const retiredHost = await nextActiveRouteAfter( + peers.host, + hostActive.revision, + ); + const retiredRoot = await nextActiveRouteAfter( + peers.secondRoot, + hostActive.revision, + ); + expect(retiredHost.assignment.sfuPublicationGeneration).toBeNull(); + expect(retiredRoot.assignment).toMatchObject({ + upstream: { kind: "peer", peerId: peers.hostAuth.peerId }, + sfuPublicationGeneration: null, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(issued).toHaveLength(3); + }); + + it("separates host signaling reconnects from new sharing generations", async () => { + const harness = await startSfuHarness({ + tokenIssuer: { + async issueToken(request) { + return `token-${request.peerId}`; + }, + }, + }); + const prepared = await prepareFallbackForTwoViewers( + harness.webSocketUrl, + harness.room, + "share-generation", + "share-gen-a", + ); + prepared.host.socket.send( + JSON.stringify({ + type: "route-ready", + revision: prepared.hostPrepare.revision, + phase: "prepare", + }), + ); + prepared.failedViewer.socket.send( + JSON.stringify({ + type: "route-ready", + revision: prepared.rootPrepare.revision, + phase: "prepare", + }), + ); + const active = await nextActiveRouteRevision( + prepared.failedViewer, + prepared.rootPrepare.revision, + ); + expect(active.assignment.upstream).toEqual({ kind: "sfu" }); + + const sameShareHost = await openClient(harness.webSocketUrl); + const sameShareAuth = peerAssisted( + await authenticate( + sameShareHost, + harness.room, + "host", + "share-generation-host-client", + 1, + "share-gen-a", + ).catch((error: unknown) => { + throw new Error("same-generation authentication failed", { + cause: error, + }); + }), + ); + expect(sameShareAuth.routeRevision).toBe(active.revision); + expect(sameShareAuth.routeAssignment.sfuPublicationGeneration).toBeTruthy(); + await expect( + prepared.failedViewer.inbox.next("sharing-stopped", 30), + ).rejects.toThrow("Timed out"); + + const nextShareHost = await openClient(harness.webSocketUrl); + const nextShareAuth = peerAssisted( + await authenticate( + nextShareHost, + harness.room, + "host", + "share-generation-host-client", + 1, + "share-gen-b", + ).catch((error: unknown) => { + throw new Error("new-generation authentication failed", { + cause: error, + }); + }), + ); + await prepared.failedViewer.inbox.next("sharing-stopped"); + expect(nextShareAuth.routeRevision).toBeLessThanOrEqual(active.revision); + expect(nextShareAuth.routeAssignment.sfuPublicationGeneration).toBeNull(); + + const staleStopClosed = new Promise((resolve) => + nextShareHost.socket.once("close", (code) => resolve(code)), + ); + nextShareHost.socket.send( + JSON.stringify({ + type: "stop-sharing", + shareGeneration: "share-gen-a", + }), + ); + expect(await staleStopClosed).toBe(4001); + await expect( + prepared.failedViewer.inbox.next("sharing-stopped", 30), + ).rejects.toThrow("Timed out"); + + const resumedSameShare = await openClient(harness.webSocketUrl); + const resumedAuth = peerAssisted( + await authenticate( + resumedSameShare, + harness.room, + "host", + "share-generation-host-client", + 1, + "share-gen-b", + ).catch((error: unknown) => { + throw new Error("resumed-generation authentication failed", { + cause: error, + }); + }), + ); + expect(resumedAuth.routeRevision).toBe(nextShareAuth.routeRevision); + }); + + it("replans a pending fallback after unrelated topology churn", async () => { + const generations: string[] = []; + const harness = await startSfuHarness({ + tokenIssuer: { + async issueToken(request) { + generations.push(request.publicationGeneration); + return `token-${request.peerId}`; + }, + }, + }); + const prepared = await prepareFallbackForTwoViewers( + harness.webSocketUrl, + harness.room, + "topology-churn", + ); + const extraViewer = await openClient(harness.webSocketUrl); + await authenticate( + extraViewer, + harness.room, + "viewer", + "topology-churn-extra", + 0, + ); + + let rollbackRevision: number | undefined; + let retriedPrepareRevision: number | undefined; + while ( + rollbackRevision === undefined || + retriedPrepareRevision === undefined + ) { + const message = await prepared.host.inbox.next("route-update"); + if ( + message.phase === "active" && + message.revision > prepared.hostPrepare.revision + ) { + rollbackRevision = message.revision; + } + if (message.phase === "prepare") { + retriedPrepareRevision = message.revision; + } + } + expect(retriedPrepareRevision).toBeGreaterThan(rollbackRevision); + expect(new Set(generations).size).toBe(2); + }); + + it("keeps a prepared fallback when an unrelated viewer disconnects", async () => { + const harness = await startSfuHarness({ + tokenIssuer: { + async issueToken(request) { + return `token-${request.peerId}`; + }, + }, + viewerDisconnectGraceMs: 500, + }); + const host = await openClient(harness.webSocketUrl); + const hostAuth = peerAssisted( + await authenticate(host, harness.room, "host", "unrelated-close-host"), + ); + const parentA = await openClient(harness.webSocketUrl); + const parentAAuth = peerAssisted( + await authenticate(parentA, harness.room, "viewer", "unrelated-parent-a"), + ); + const parentB = await openClient(harness.webSocketUrl); + await authenticate(parentB, harness.room, "viewer", "unrelated-parent-b"); + const failedViewer = await openClient(harness.webSocketUrl); + const failedAuth = peerAssisted( + await authenticate( + failedViewer, + harness.room, + "viewer", + "unrelated-failed", + ), + ); + const unrelatedViewer = await openClient(harness.webSocketUrl); + const unrelatedAuth = peerAssisted( + await authenticate( + unrelatedViewer, + harness.room, + "viewer", + "unrelated-disconnected", + 0, + ), + ); + expect(failedAuth.routeAssignment.upstream).toEqual({ + kind: "peer", + peerId: parentAAuth.peerId, + }); + + parentA.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: failedAuth.peerId, + payload: { + kind: "description", + connectionId: "unrelated-close-failed-edge", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await failedViewer.inbox.next("signal"); + failedViewer.socket.send( + JSON.stringify({ + type: "route-failed", + revision: unrelatedAuth.routeRevision, + phase: "active", + connectionId: "unrelated-close-failed-edge", + }), + ); + const hostPrepare = await nextPreparedRoute(host).catch((error: unknown) => { + throw new Error("host did not receive unrelated-close prepare", { + cause: error, + }); + }); + const failedPrepare = await nextPreparedRoute(failedViewer).catch( + (error: unknown) => { + throw new Error("failed viewer did not receive unrelated-close prepare", { + cause: error, + }); + }, + ); + const parentPrepare = await nextPreparedRoute(parentA); + await host.inbox.next("sfu-config"); + await failedViewer.inbox.next("sfu-config"); + await parentA.inbox.next("sfu-config"); + + await closeClient(unrelatedViewer); + host.socket.send( + JSON.stringify({ + type: "route-ready", + revision: hostPrepare.revision, + phase: "prepare", + }), + ); + failedViewer.socket.send( + JSON.stringify({ + type: "route-ready", + revision: failedPrepare.revision, + phase: "prepare", + }), + ); + parentA.socket.send( + JSON.stringify({ + type: "route-ready", + revision: parentPrepare.revision, + phase: "prepare", + }), + ); + const committed = await nextActiveRouteRevision( + host, + hostPrepare.revision, + ).catch((error: unknown) => { + throw new Error("unrelated close aborted the prepared route", { + cause: error, + }); + }); + expect(committed.assignment).toMatchObject({ + upstream: { kind: "none" }, + sfuPublicationGeneration: expect.any(String), + }); + expect(committed.assignment.childPeerIds.length).toBeLessThanOrEqual(1); + expect(hostAuth.peerId).toBeTruthy(); + }); + + it("rolls back SFU prepare on timeout and expected-session replacement", async () => { + const harness = await startSfuHarness({ + tokenIssuer: { + async issueToken(request) { + return `token-${request.peerId}`; + }, + }, + prepareTimeoutMs: 120, + }); + const timedOut = await prepareFallbackForTwoViewers( + harness.webSocketUrl, + harness.room, + "timeout", + ); + const timedOutRollback = await nextActiveRouteAfter( + timedOut.host, + timedOut.hostPrepare.revision, + ); + expect(timedOutRollback).toMatchObject({ + revision: timedOut.hostPrepare.revision + 1, + assignment: { + upstream: { kind: "none" }, + childPeerIds: [timedOut.rootAuth.peerId], + sfuPublicationGeneration: null, + }, + }); + + const replacementRoom = harness.roomStore.createRoom(); + const replacing = await prepareFallbackForTwoViewers( + harness.webSocketUrl, + replacementRoom, + "replacement", + ); + const replacementRoot = await openClient(harness.webSocketUrl); + const replacementAuth = peerAssisted( + await authenticate( + replacementRoot, + replacementRoom, + "viewer", + "replacement-failed-client", + ), + ); + expect(replacementAuth).toMatchObject({ + peerId: replacing.failedAuth.peerId, + routeRevision: replacing.hostPrepare.revision + 1, + routeAssignment: { + upstream: { kind: "peer", peerId: replacing.rootAuth.peerId }, + sfuPublicationGeneration: null, + }, + }); + const replacementRollback = await nextActiveRouteAfter( + replacing.host, + replacing.hostPrepare.revision, + ); + expect(replacementRollback.revision).toBe( + replacing.hostPrepare.revision + 1, + ); + + replacing.rootViewer.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: replacementAuth.peerId, + payload: { + kind: "description", + connectionId: "replacement-reported-again", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await replacementRoot.inbox.next("signal"); + replacementRoot.socket.send( + JSON.stringify({ + type: "route-failed", + revision: replacementAuth.routeRevision, + phase: "active", + connectionId: "replacement-reported-again", + }), + ); + const replacementRecovered = await nextActiveRouteAfter( + replacementRoot, + replacementAuth.routeRevision, + ); + expect(replacementRecovered.assignment.upstream).toEqual({ + kind: "peer", + peerId: replacing.hostAuth.peerId, + }); + + replacing.failedViewer.socket.send( + JSON.stringify({ + type: "route-ready", + revision: replacing.hostPrepare.revision, + phase: "prepare", + }), + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(replacementRoot.socket.readyState).toBe(WebSocket.OPEN); + + const prepareFailureRoom = harness.roomStore.createRoom(); + const prepareFailure = await prepareFallbackForTwoViewers( + harness.webSocketUrl, + prepareFailureRoom, + "prepare-failure", + ); + prepareFailure.failedViewer.socket.send( + JSON.stringify({ + type: "route-failed", + revision: prepareFailure.rootPrepare.revision, + phase: "prepare", + connectionId: null, + }), + ); + const failedPrepareRollback = await nextActiveRouteAfter( + prepareFailure.host, + prepareFailure.hostPrepare.revision, + ); + expect(failedPrepareRollback.revision).toBe( + prepareFailure.hostPrepare.revision + 1, + ); + expect(failedPrepareRollback.assignment).toMatchObject({ + childPeerIds: [prepareFailure.rootAuth.peerId], + sfuPublicationGeneration: null, + }); + }); + + it("fails back to peer routes when an active SFU token refresh fails", async () => { + let rejectTokens = false; + const harness = await startSfuHarness({ + tokenIssuer: { + async issueToken(request) { + if (rejectTokens) { + throw new Error("issuer unavailable"); + } + return `token-${request.peerId}`; + }, + }, + }); + const prepared = await prepareFallbackForTwoViewers( + harness.webSocketUrl, + harness.room, + "refresh-failure", + ); + prepared.host.socket.send( + JSON.stringify({ + type: "route-ready", + revision: prepared.hostPrepare.revision, + phase: "prepare", + }), + ); + prepared.failedViewer.socket.send( + JSON.stringify({ + type: "route-ready", + revision: prepared.rootPrepare.revision, + phase: "prepare", + }), + ); + await nextActiveRouteAfter( + prepared.host, + prepared.hostPrepare.revision - 1, + ); + await nextActiveRouteAfter( + prepared.failedViewer, + prepared.rootPrepare.revision - 1, + ); + + rejectTokens = true; + prepared.failedViewer.socket.send( + JSON.stringify({ + type: "refresh-sfu", + revision: prepared.rootPrepare.revision, + }), + ); + const rollback = await nextActiveRouteAfter( + prepared.host, + prepared.hostPrepare.revision, + ); + expect(rollback).toMatchObject({ + revision: prepared.hostPrepare.revision + 1, + assignment: { + childPeerIds: [prepared.rootAuth.peerId], + sfuPublicationGeneration: null, + }, + }); + }); + + it("keeps route revisions monotonic when a disconnected host is replaced", async () => { + const harness = await startHarness({ peerAssistedMedia: true }); + const firstHost = await openClient(harness.webSocketUrl); + const firstHostAuth = peerAssisted( + await authenticate(firstHost, harness.room, "host", "takeover-host-a"), + ); + const viewer = await openClient(harness.webSocketUrl); + const viewerAuth = peerAssisted( + await authenticate(viewer, harness.room, "viewer", "takeover-viewer-a"), + ); + const secondViewer = await openClient(harness.webSocketUrl); + await authenticate( + secondViewer, + harness.room, + "viewer", + "takeover-viewer-b", + ); + const previousRoute = await nextActiveRouteAfter( + viewer, + viewerAuth.routeRevision, + ); + + await closeClient(firstHost); + await viewer.inbox.next("host-status"); + const replacementHost = await openClient(harness.webSocketUrl); + const replacementAuth = peerAssisted( + await authenticate( + replacementHost, + harness.room, + "host", + "takeover-host-b", + ), + ); + expect(replacementAuth.peerId).not.toBe(firstHostAuth.peerId); + expect(replacementAuth.routeRevision).toBeGreaterThan( + previousRoute.revision, + ); + const viewerRoute = await nextActiveRouteAfter( + viewer, + previousRoute.revision, + ); + expect(viewerRoute).toMatchObject({ + revision: replacementAuth.routeRevision, + assignment: { + upstream: { kind: "peer", peerId: replacementAuth.peerId }, + }, + }); + }); + it("keeps relay assignments through grace and reattaches only the direct child subtree", async () => { const harness = await startHarness({ peerAssistedMedia: true, @@ -738,6 +2273,8 @@ describe("WebSocket signaling", () => { childPeerIds: [fifthAuth.peerId], }); + host.inbox.ignore("route-update"); + thirdViewer.inbox.ignore("route-update"); await closeClient(firstViewer); await host.inbox.expectNone(30); await thirdViewer.inbox.expectNone(30); @@ -792,6 +2329,7 @@ describe("WebSocket signaling", () => { parentPeerId: hostAuth.peerId, childPeerIds: [fifthAuth.peerId], }); + fifthViewer.inbox.ignore("route-update"); await fifthViewer.inbox.expectNone(30); }); @@ -1127,6 +2665,50 @@ describe("WebSocket signaling", () => { }); }); + it("reasserts an authoritative parent route when a peer-assisted viewer reconnects", async () => { + const harness = await startHarness({ + peerAssistedMedia: true, + viewerDisconnectGraceMs: 300, + }); + const host = await openClient(harness.webSocketUrl); + peerAssisted( + await authenticate(host, harness.room, "host", "relay-reauth-host"), + ); + const originalViewer = await openClient(harness.webSocketUrl); + const originalAuth = peerAssisted( + await authenticate( + originalViewer, + harness.room, + "viewer", + "relay-reauth-viewer", + ), + ); + await host.inbox.next("media-assignment"); + await host.inbox.next("route-update"); + await closeClient(originalViewer); + + const reconnectedViewer = await openClient(harness.webSocketUrl); + const reconnectedAuth = peerAssisted( + await authenticate( + reconnectedViewer, + harness.room, + "viewer", + "relay-reauth-viewer", + ), + ); + expect(reconnectedAuth.peerId).toBe(originalAuth.peerId); + expect(reconnectedAuth.connectionId).toBeNull(); + expect(await host.inbox.next("route-update")).toMatchObject({ + revision: reconnectedAuth.routeRevision, + phase: "active", + assignment: { + upstream: { kind: "none" }, + childPeerIds: [originalAuth.peerId], + sfuPublicationGeneration: null, + }, + }); + }); + it("replays viewers when a host reconnects", async () => { const harness = await startHarness({ viewerDisconnectGraceMs: 500 }); const viewer = await openClient(harness.webSocketUrl); diff --git a/tests/sfu-client.test.ts b/tests/sfu-client.test.ts new file mode 100644 index 0000000..c78c34d --- /dev/null +++ b/tests/sfu-client.test.ts @@ -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 => { + 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 => { + this.currentTrack = nextTrack; + }, + ); + } + + class FakeLocalParticipant { + readonly publications: Array<{ + track: FakeLocalTrack; + videoTrack?: FakeLocalTrack; + rawTrack: MediaStreamTrack; + options: Record; + }> = []; + + readonly publishTrack = vi.fn( + async ( + rawTrack: MediaStreamTrack, + options: Record, + ) => { + 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(); + + 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(); + readonly handlers = new Map(); + readonly connect = vi.fn( + async ( + _url: string, + _token: string, + _options: Record, + ): Promise => { + 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 | 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; + resolve: () => void; +} { + let resolve = (): void => undefined; + const promise = new Promise((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 = []; + 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"); + }); +}); diff --git a/tests/viewer-message-authority.test.ts b/tests/viewer-message-authority.test.ts new file mode 100644 index 0000000..f602363 --- /dev/null +++ b/tests/viewer-message-authority.test.ts @@ -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); + }); +}); diff --git a/tests/viewer-peer-generation.test.ts b/tests/viewer-peer-generation.test.ts index 2c952cf..83f4a16 100644 --- a/tests/viewer-peer-generation.test.ts +++ b/tests/viewer-peer-generation.test.ts @@ -104,6 +104,8 @@ class FakePeerConnection extends EventTarget { const intervalCallbacks = new Map void>(); let nextIntervalId = 1; +const timeoutCallbacks = new Map 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[] = [];