feat(media): add bounded automatic hybrid routing

This commit is contained in:
AAA committed 2026-08-19 12:38:13 +08:00
1 parent 38c270d4da
commit 96b2d4f1e6
33 files changed
+9974 -207

No files matched your search

+157
View File
@@ -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",
+2
View File
@@ -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",
+212
View File
@@ -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.
+520
View File
@@ -0,0 +1,520 @@
import type {
ClientMessage,
MediaRoutePhase,
ParticipantRouteAssignment,
ServerMessage,
} from "../../shared/protocol";
import { SfuPublisher, type SfuConnectionConfig } from "../sfu/publisher";
import type { QualityProfile } from "./quality";
import {
MediaRouteTransition,
type RouteOperationToken,
type RouteUpdateInput,
type RouteUpdateResult,
} from "./route-transition";
interface HostPublisherTransport {
connect(config: SfuConnectionConfig): Promise<boolean>;
activate(stream: MediaStream, profile: QualityProfile): Promise<boolean>;
deactivate(): Promise<boolean>;
replaceStream(stream: MediaStream): Promise<boolean>;
updateProfile(profile: QualityProfile): Promise<boolean>;
disconnect(): Promise<void>;
}
interface HostPublisherSlot {
revision: number;
publicationGeneration: string;
publisher: HostPublisherTransport;
connected: boolean;
active: boolean;
failed: boolean;
acknowledgeActive: boolean;
}
interface HostSfuRouteEvents {
getStream: () => MediaStream | null;
getProfile: () => QualityProfile;
reconcileChildren: (childPeerIds: string[]) => void;
send: (message: ClientMessage) => boolean;
createPublisher?: (onDisconnected: () => void) => HostPublisherTransport;
}
export class HostSfuRoute {
private readonly route = new MediaRouteTransition();
private pending: HostPublisherSlot | null = null;
private active: HostPublisherSlot | null = null;
private recovery: { revision: number; refreshed: boolean } | null = null;
private transitionTail: Promise<void> = Promise.resolve();
private resyncGeneration = 0;
private resyncing = false;
private closed = false;
constructor(private readonly events: HostSfuRouteEvents) {}
accept(update: RouteUpdateInput, acknowledge = true): RouteUpdateResult {
if (this.closed) {
return "stale";
}
const previousRevision = this.route.getRevision();
const result = this.route.accept(update);
if (result === "stale") {
return result;
}
if (previousRevision !== update.revision) {
this.recovery = null;
}
if (update.phase === "prepare") {
if (
this.pending &&
(this.pending.revision !== update.revision ||
this.pending.publicationGeneration !==
update.assignment.sfuPublicationGeneration)
) {
this.clearPending();
}
if (update.assignment.sfuPublicationGeneration === null) {
this.clearPending();
}
return result;
}
const token = this.route.token();
if (token && !this.resyncing) {
void this.queueActivation(token, acknowledge);
}
return result;
}
async resyncAuthoritative(
update: RouteUpdateInput,
): Promise<RouteUpdateResult> {
const result = this.accept(update, false);
if (result !== "stale" || this.closed) {
return result;
}
const resyncGeneration = ++this.resyncGeneration;
this.resyncing = true;
this.route.reset();
this.recovery = null;
const pending = this.pending;
const active = this.active;
this.pending = null;
this.active = null;
if (pending) {
pending.failed = true;
}
if (active) {
active.failed = true;
}
const accepted = this.route.accept(update);
this.events.reconcileChildren([]);
await this.queueTransition(async () => {
if (active?.active) {
await active.publisher.deactivate().catch(() => false);
}
if (active) {
await disconnectPublisher(active.publisher);
}
if (pending && pending !== active) {
await disconnectPublisher(pending.publisher);
}
});
if (this.closed || this.resyncGeneration !== resyncGeneration) {
return "stale";
}
this.resyncing = false;
const token = this.route.token();
if (token && this.route.getPhase() === "active") {
await this.queueActivation(token, false);
}
return accepted;
}
async acceptConfig(
message: Extract<ServerMessage, { type: "sfu-config" }>,
): Promise<void> {
if (this.closed || !this.route.acceptsConfig(message.revision)) {
return;
}
const token = this.route.token();
const assignment = this.route.getPlannedAssignment();
const phase = this.route.getPhase();
const publicationGeneration = assignment?.sfuPublicationGeneration;
if (!token || !phase || !assignment || !publicationGeneration) {
return;
}
if (this.active?.publicationGeneration === publicationGeneration) {
this.active.revision = message.revision;
if (phase === "prepare" && this.active.connected) {
this.ready(message.revision, "prepare");
} else if (phase === "active" && this.active.active) {
await this.queueActivation(token, true);
}
return;
}
if (
this.pending?.revision === message.revision &&
this.pending.publicationGeneration === publicationGeneration &&
!this.pending.failed
) {
if (this.pending.connected && phase === "prepare") {
this.ready(message.revision, "prepare");
}
return;
}
this.clearPending();
let slot: HostPublisherSlot;
const publisher =
this.events.createPublisher?.(() => this.handleFailure(slot)) ??
new SfuPublisher({ onDisconnected: () => this.handleFailure(slot) });
slot = {
revision: message.revision,
publicationGeneration,
publisher,
connected: false,
active: false,
failed: false,
acknowledgeActive: false,
};
this.pending = slot;
try {
const connected = await publisher.connect({
url: message.url,
token: message.token,
});
if (!connected) {
if (this.pending === slot && this.route.owns(token)) {
this.handleFailure(slot);
}
await disconnectPublisher(publisher);
return;
}
if (this.pending !== slot || !this.route.owns(token)) {
await disconnectPublisher(publisher);
return;
}
slot.connected = true;
if (phase === "prepare") {
this.ready(message.revision, "prepare");
} else if (!this.resyncing) {
await this.queueActivation(token, true);
}
} catch {
await disconnectPublisher(publisher);
this.handleFailure(slot);
}
}
updateProfile(profile: QualityProfile): Promise<boolean> {
return this.active?.active
? this.active.publisher.updateProfile(profile).catch(() => false)
: Promise.resolve(true);
}
replaceStream(stream: MediaStream): Promise<boolean> {
return this.active
? this.active.publisher.replaceStream(stream).catch(() => false)
: Promise.resolve(true);
}
async failActivePublisher(): Promise<void> {
await this.queueTransition(async () => {
const slot = this.active;
if (!slot) {
return;
}
this.active = null;
await disconnectPublisher(slot.publisher);
this.handleFailure(slot);
});
}
async disconnect(): Promise<void> {
if (this.closed) {
return;
}
this.closed = true;
this.resyncGeneration += 1;
this.resyncing = false;
this.route.reset();
this.recovery = null;
await this.queueTransition(async () => {
const pending = this.pending;
const active = this.active;
this.pending = null;
this.active = null;
if (active?.active) {
await active.publisher.deactivate().catch(() => false);
}
await Promise.all([
pending ? disconnectPublisher(pending.publisher) : undefined,
active && active !== pending
? disconnectPublisher(active.publisher)
: undefined,
]);
});
}
private queueActivation(
token: RouteOperationToken,
acknowledge: boolean,
): Promise<void> {
return this.queueTransition(async () => {
if (this.closed || this.resyncing || !this.route.owns(token, "active")) {
return;
}
const assignment = this.route.getActiveAssignment();
if (assignment) {
await this.activate(assignment, token, acknowledge);
}
});
}
private queueTransition(work: () => Promise<void>): Promise<void> {
const next = this.transitionTail.then(work, work);
this.transitionTail = next.catch(() => undefined);
return this.transitionTail;
}
private async activate(
assignment: ParticipantRouteAssignment,
token: RouteOperationToken,
acknowledge: boolean,
): Promise<void> {
const publicationGeneration = assignment.sfuPublicationGeneration;
if (publicationGeneration === null) {
this.clearPending();
await this.retireActive();
if (!this.route.owns(token, "active")) {
return;
}
this.events.reconcileChildren(assignment.childPeerIds);
this.commitMedia(token, acknowledge);
return;
}
this.events.reconcileChildren(assignment.childPeerIds);
if (
this.active &&
!this.active.failed &&
this.active.publicationGeneration === publicationGeneration
) {
this.active.revision = token.revision;
this.clearPending();
if (this.active.active) {
this.commitMedia(token, acknowledge);
} else {
this.active.acknowledgeActive ||= acknowledge;
}
return;
}
const pending = this.pending;
if (
!pending ||
pending.revision !== token.revision ||
pending.publicationGeneration !== publicationGeneration ||
!pending.connected
) {
await this.retireActive();
if (acknowledge && this.route.owns(token, "active")) {
this.requestRecovery(token.revision);
}
return;
}
await this.retireActive(pending);
if (!this.route.owns(token, "active")) {
this.clearPending(pending);
return;
}
this.pending = null;
this.active = pending;
pending.acknowledgeActive ||= acknowledge;
const publishedStream = this.events.getStream();
const publishedProfile = this.events.getProfile();
if (!publishedStream) {
await disconnectPublisher(pending.publisher);
this.handleFailure(pending);
return;
}
try {
const activated = await pending.publisher.activate(
publishedStream,
publishedProfile,
);
const activeToken = this.currentActiveToken(pending);
if (!activated || !activeToken) {
if (this.active === pending) {
this.active = null;
}
await disconnectPublisher(pending.publisher);
if (!activated && activeToken) {
this.handleFailure(pending);
}
return;
}
pending.active = true;
const latestStream = this.events.getStream();
if (
latestStream &&
latestStream !== publishedStream &&
!(await pending.publisher.replaceStream(latestStream))
) {
await disconnectPublisher(pending.publisher);
this.handleFailure(pending);
return;
}
const latestProfile = this.events.getProfile();
if (
latestProfile !== publishedProfile &&
!(await pending.publisher.updateProfile(latestProfile))
) {
await disconnectPublisher(pending.publisher);
this.handleFailure(pending);
return;
}
} catch {
await disconnectPublisher(pending.publisher);
this.handleFailure(pending);
return;
}
const activeToken = this.currentActiveToken(pending);
if (!activeToken) {
if (this.active === pending) {
this.active = null;
}
await disconnectPublisher(pending.publisher);
return;
}
if (!this.route.owns(token, "active")) {
pending.acknowledgeActive = false;
return;
}
const acknowledgeActive = pending.acknowledgeActive;
pending.acknowledgeActive = false;
this.commitMedia(activeToken, acknowledgeActive);
}
private currentActiveToken(
slot: HostPublisherSlot,
): RouteOperationToken | null {
const token = this.route.token();
const assignment = this.route.getActiveAssignment();
if (
this.active !== slot ||
!token ||
this.route.getPhase() !== "active" ||
assignment?.sfuPublicationGeneration !== slot.publicationGeneration
) {
return null;
}
return token;
}
private commitMedia(token: RouteOperationToken, acknowledge: boolean): void {
if (!this.route.markMediaActive(token)) {
return;
}
this.recovery = null;
if (acknowledge) {
this.ready(token.revision, "active");
}
}
private clearPending(expected?: HostPublisherSlot): void {
if (!this.pending || (expected && this.pending !== expected)) {
return;
}
const pending = this.pending;
this.pending = null;
void disconnectPublisher(pending.publisher);
}
private async retireActive(except?: HostPublisherSlot): Promise<void> {
if (!this.active || this.active === except) {
return;
}
const active = this.active;
this.active = null;
if (active.active) {
await active.publisher.deactivate().catch(() => false);
}
await disconnectPublisher(active.publisher);
}
private handleFailure(slot: HostPublisherSlot): void {
if (this.closed || slot.failed) {
return;
}
const wasActive = this.active === slot;
slot.failed = true;
if (this.pending === slot) {
this.pending = null;
}
if (wasActive) {
this.active = null;
}
const assignment = this.route.getPlannedAssignment();
const revision = this.route.getRevision();
const phase = this.route.getPhase();
const matchesPlannedRoute =
revision === slot.revision &&
assignment?.sfuPublicationGeneration === slot.publicationGeneration;
if (revision === null || phase === null) {
return;
}
if (phase === "prepare") {
if (matchesPlannedRoute || wasActive) {
this.routeFailed(revision, "prepare");
}
return;
}
if (!matchesPlannedRoute) {
return;
}
this.requestRecovery(revision);
}
private requestRecovery(revision: number): void {
if (!this.recovery || this.recovery.revision !== revision) {
this.recovery = { revision, refreshed: false };
}
if (!this.recovery.refreshed) {
this.recovery.refreshed = true;
if (this.events.send({ type: "refresh-sfu", revision })) {
return;
}
}
this.routeFailed(revision, "active");
}
private routeFailed(revision: number, phase: MediaRoutePhase): void {
this.events.send({
type: "route-failed",
revision,
phase,
connectionId: null,
});
}
private ready(revision: number, phase: MediaRoutePhase): void {
this.events.send({ type: "route-ready", revision, phase });
}
}
async function disconnectPublisher(
publisher: HostPublisherTransport,
): Promise<void> {
await publisher.disconnect().catch(() => undefined);
}
+51
View File
@@ -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,
};
}
+164
View File
@@ -0,0 +1,164 @@
import type {
ClientMessage,
MediaRoutePhase,
ParticipantRouteAssignment,
} from "../../shared/protocol";
export interface RouteUpdateInput {
revision: number;
phase: MediaRoutePhase;
assignment: ParticipantRouteAssignment;
}
export interface RouteOperationToken {
revision: number;
generation: number;
}
export type RouteUpdateResult = "accepted" | "duplicate" | "stale";
export class MediaRouteTransition {
private revision = -1;
private phase: MediaRoutePhase | null = null;
private plannedAssignment: ParticipantRouteAssignment | null = null;
private activeAssignment: ParticipantRouteAssignment | null = null;
private mediaAssignment: ParticipantRouteAssignment | null = null;
private generation = 0;
accept(update: RouteUpdateInput): RouteUpdateResult {
if (update.revision < this.revision) {
return "stale";
}
if (update.revision === this.revision) {
if (
!this.plannedAssignment ||
!sameAssignment(this.plannedAssignment, update.assignment) ||
(this.phase === "active" && update.phase === "prepare")
) {
return "stale";
}
if (this.phase === update.phase) {
return "duplicate";
}
}
this.revision = update.revision;
this.phase = update.phase;
this.plannedAssignment = cloneAssignment(update.assignment);
if (update.phase === "active") {
this.activeAssignment = cloneAssignment(update.assignment);
}
this.generation += 1;
return "accepted";
}
acceptsConfig(revision: number): boolean {
return revision === this.revision && this.phase !== null;
}
token(): RouteOperationToken | null {
if (this.revision < 0 || this.phase === null) {
return null;
}
return { revision: this.revision, generation: this.generation };
}
owns(token: RouteOperationToken, phase?: MediaRoutePhase): boolean {
return (
token.revision === this.revision &&
token.generation === this.generation &&
(phase === undefined || this.phase === phase)
);
}
markMediaActive(token: RouteOperationToken): boolean {
if (!this.owns(token, "active") || !this.activeAssignment) {
return false;
}
this.mediaAssignment = cloneAssignment(this.activeAssignment);
return true;
}
getRevision(): number | null {
return this.revision < 0 ? null : this.revision;
}
getPhase(): MediaRoutePhase | null {
return this.phase;
}
getPlannedAssignment(): ParticipantRouteAssignment | null {
return this.plannedAssignment
? cloneAssignment(this.plannedAssignment)
: null;
}
getActiveAssignment(): ParticipantRouteAssignment | null {
return this.activeAssignment ? cloneAssignment(this.activeAssignment) : null;
}
getMediaAssignment(): ParticipantRouteAssignment | null {
return this.mediaAssignment ? cloneAssignment(this.mediaAssignment) : null;
}
reset(): void {
this.revision = -1;
this.phase = null;
this.plannedAssignment = null;
this.activeAssignment = null;
this.mediaAssignment = null;
this.generation += 1;
}
}
export function reportActivePeerRouteFailure(
route: MediaRouteTransition,
parentPeerId: string,
connectionId: string,
send: (message: ClientMessage) => boolean,
): boolean {
const assignment = route.getActiveAssignment();
const revision = route.getRevision();
if (
route.getPhase() !== "active" ||
revision === null ||
assignment?.upstream.kind !== "peer" ||
assignment.upstream.peerId !== parentPeerId
) {
return true;
}
return send({
type: "route-failed",
revision,
phase: "active",
connectionId,
});
}
function sameAssignment(
left: ParticipantRouteAssignment,
right: ParticipantRouteAssignment,
): boolean {
return (
left.upstream.kind === right.upstream.kind &&
(left.upstream.kind !== "peer" ||
(right.upstream.kind === "peer" &&
left.upstream.peerId === right.upstream.peerId)) &&
left.sfuPublicationGeneration === right.sfuPublicationGeneration &&
left.childPeerIds.length === right.childPeerIds.length &&
left.childPeerIds.every((peerId, index) => peerId === right.childPeerIds[index])
);
}
function cloneAssignment(
assignment: ParticipantRouteAssignment,
): ParticipantRouteAssignment {
return {
upstream:
assignment.upstream.kind === "peer"
? { kind: "peer", peerId: assignment.upstream.peerId }
: { kind: assignment.upstream.kind },
childPeerIds: [...assignment.childPeerIds],
sfuPublicationGeneration: assignment.sfuPublicationGeneration,
};
}
@@ -0,0 +1,29 @@
import type { ServerMessage } from "../../shared/protocol";
const AUTHORITY_MESSAGE_TYPES = new Set<ServerMessage["type"]>([
"authenticated",
"ice-config",
"host-status",
"sharing-stopped",
"room-closed",
"error",
]);
export class ViewerMessageAuthority {
private generation = 0;
tokenFor(message: ServerMessage): number {
if (AUTHORITY_MESSAGE_TYPES.has(message.type)) {
this.generation += 1;
}
return this.generation;
}
invalidate(): void {
this.generation += 1;
}
owns(token: number): boolean {
return token === this.generation;
}
}
+495
View File
@@ -0,0 +1,495 @@
import type {
ClientMessage,
MediaRoutePhase,
ParticipantRouteAssignment,
ServerMessage,
} from "../../shared/protocol";
import { SfuSubscriber } from "../sfu/subscriber";
import type { SfuConnectionConfig } from "../sfu/publisher";
import {
MediaRouteTransition,
reportActivePeerRouteFailure,
type RouteOperationToken,
type RouteUpdateInput,
type RouteUpdateResult,
} from "./route-transition";
interface ViewerSubscriberTransport {
connect(config: SfuConnectionConfig): Promise<boolean>;
activate(): boolean;
deactivate(): boolean;
disconnect(): Promise<void>;
}
interface ViewerSubscriberSlot {
revision: number;
subscriber: ViewerSubscriberTransport;
connected: boolean;
activated: boolean;
failed: boolean;
activationToken: RouteOperationToken | null;
}
interface ViewerSfuRouteEvents {
activatePeer: (
assignment: ParticipantRouteAssignment,
) => void | Promise<void>;
preparePeer?: (assignment: ParticipantRouteAssignment | null) => void;
resetMedia?: () => void;
reconcileSfuChildren: (childPeerIds: string[]) => void;
onSfuStream: (
stream: MediaStream,
assignment: ParticipantRouteAssignment,
initialVideoStream: boolean,
) => void;
send: (message: ClientMessage) => boolean;
createSubscriber?: (
events: {
onStream: (stream: MediaStream | null) => void;
onDisconnected: () => void;
},
) => ViewerSubscriberTransport;
}
export class ViewerSfuRoute {
private readonly route = new MediaRouteTransition();
private pending: ViewerSubscriberSlot | null = null;
private active: ViewerSubscriberSlot | null = null;
private recovery: { revision: number; refreshed: boolean } | null = null;
private transitionTail: Promise<void> = Promise.resolve();
private resyncGeneration = 0;
private resyncing = false;
private closed = false;
constructor(private readonly events: ViewerSfuRouteEvents) {}
accept(update: RouteUpdateInput, acknowledge = true): RouteUpdateResult {
if (this.closed) {
return "stale";
}
const previousRevision = this.route.getRevision();
const result = this.route.accept(update);
if (result === "stale") {
return result;
}
if (previousRevision !== update.revision) {
this.recovery = null;
}
if (update.phase === "prepare") {
if (this.pending?.revision !== update.revision) {
this.clearPending();
}
if (update.assignment.upstream.kind !== "sfu") {
this.clearPending();
}
return result;
}
const token = this.route.token();
if (!token) {
return result;
}
this.preparePeer(update.assignment);
if (this.resyncing) {
return result;
}
void this.queueActiveRoute(token, acknowledge);
return result;
}
async resyncAuthoritative(
update: RouteUpdateInput,
): Promise<RouteUpdateResult> {
const result = this.accept(update, false);
if (result !== "stale" || this.closed) {
return result;
}
const resyncGeneration = ++this.resyncGeneration;
this.resyncing = true;
this.route.reset();
this.recovery = null;
const pending = this.pending;
const active = this.active;
this.pending = null;
this.active = null;
if (pending) {
pending.failed = true;
}
if (active) {
active.failed = true;
}
const accepted = this.route.accept(update);
this.preparePeer(update.assignment);
this.events.resetMedia?.();
await this.queueTransition(async () => {
if (active?.activated) {
try {
active.subscriber.deactivate();
} catch {
// Disconnect remains the fail-closed cleanup path.
}
}
if (active) {
await disconnectSubscriber(active.subscriber);
}
if (pending && pending !== active) {
await disconnectSubscriber(pending.subscriber);
}
});
if (this.closed || this.resyncGeneration !== resyncGeneration) {
return "stale";
}
this.resyncing = false;
const token = this.route.token();
if (token && this.route.getPhase() === "active") {
await this.queueActiveRoute(token, false);
}
return accepted;
}
async acceptConfig(
message: Extract<ServerMessage, { type: "sfu-config" }>,
): Promise<void> {
if (this.closed || !this.route.acceptsConfig(message.revision)) {
return;
}
const token = this.route.token();
const assignment = this.route.getPlannedAssignment();
const phase = this.route.getPhase();
if (!token || !phase || assignment?.upstream.kind !== "sfu") {
return;
}
if (this.active?.revision === message.revision && this.active.activated) {
if (phase === "prepare") {
this.ready(message.revision, "prepare");
}
return;
}
if (this.pending?.revision === message.revision && !this.pending.failed) {
if (this.pending.connected && phase === "prepare") {
this.ready(message.revision, "prepare");
}
return;
}
this.clearPending();
let slot: ViewerSubscriberSlot;
const subscriberEvents = {
onStream: (stream: MediaStream | null) => {
if (stream) {
this.handleStream(slot, stream);
}
},
onDisconnected: () => this.handleFailure(slot),
};
const subscriber =
this.events.createSubscriber?.(subscriberEvents) ??
new SfuSubscriber(subscriberEvents);
slot = {
revision: message.revision,
subscriber,
connected: false,
activated: false,
failed: false,
activationToken: null,
};
this.pending = slot;
try {
const connected = await subscriber.connect({
url: message.url,
token: message.token,
});
if (!connected) {
if (this.pending === slot && this.route.owns(token)) {
this.handleFailure(slot);
}
await disconnectSubscriber(subscriber);
return;
}
if (this.pending !== slot || !this.route.owns(token)) {
await disconnectSubscriber(subscriber);
return;
}
slot.connected = true;
if (phase === "prepare") {
this.ready(message.revision, "prepare");
} else if (!this.resyncing) {
await this.queueActiveRoute(token, true);
}
} catch {
await disconnectSubscriber(subscriber);
this.handleFailure(slot);
}
}
reportPeerFailure(parentPeerId: string, connectionId: string): boolean {
return reportActivePeerRouteFailure(
this.route,
parentPeerId,
connectionId,
this.events.send,
);
}
async disconnect(): Promise<void> {
if (this.closed) {
return;
}
this.closed = true;
this.resyncGeneration += 1;
this.resyncing = false;
this.route.reset();
this.recovery = null;
this.events.preparePeer?.(null);
await this.queueTransition(async () => {
const pending = this.pending;
const active = this.active;
this.pending = null;
this.active = null;
if (active?.activated) {
try {
active.subscriber.deactivate();
} catch {
// Disconnect remains the fail-closed cleanup path.
}
}
await Promise.all([
pending ? disconnectSubscriber(pending.subscriber) : undefined,
active && active !== pending
? disconnectSubscriber(active.subscriber)
: undefined,
]);
});
}
private preparePeer(assignment: ParticipantRouteAssignment): void {
this.events.preparePeer?.(
assignment.upstream.kind === "peer" ? assignment : null,
);
}
private queueActiveRoute(
token: RouteOperationToken,
acknowledge: boolean,
): Promise<void> {
return this.queueTransition(async () => {
if (this.closed || this.resyncing || !this.route.owns(token, "active")) {
return;
}
const assignment = this.route.getActiveAssignment();
if (!assignment) {
return;
}
if (assignment.upstream.kind === "sfu") {
this.events.reconcileSfuChildren(assignment.childPeerIds);
const mediaAssignment = this.route.getMediaAssignment();
if (
mediaAssignment?.upstream.kind === "sfu" &&
this.active?.activated &&
this.pending?.revision !== token.revision
) {
this.clearPending();
this.active.revision = token.revision;
this.commitMedia(token, acknowledge);
return;
}
await this.activateSfu(token, acknowledge);
return;
}
this.clearPending();
if (this.active) {
await this.retireActive();
}
if (!this.route.owns(token, "active")) {
return;
}
await this.events.activatePeer(assignment);
if (!this.route.owns(token, "active")) {
return;
}
this.commitMedia(token, acknowledge);
});
}
private queueTransition(work: () => Promise<void>): Promise<void> {
const next = this.transitionTail.then(work, work);
this.transitionTail = next.catch(() => undefined);
return this.transitionTail;
}
private async activateSfu(
token: RouteOperationToken,
acknowledge: boolean,
): Promise<void> {
if (this.active?.revision === token.revision && this.active.activated) {
return;
}
const pending = this.pending;
if (!pending || pending.revision !== token.revision || !pending.connected) {
if (acknowledge && this.route.owns(token, "active")) {
this.requestRecovery(token.revision);
}
return;
}
pending.activationToken = token;
try {
if (!pending.subscriber.activate()) {
await disconnectSubscriber(pending.subscriber);
this.handleFailure(pending);
return;
}
pending.activated = true;
} catch {
await disconnectSubscriber(pending.subscriber);
this.handleFailure(pending);
}
}
private handleStream(slot: ViewerSubscriberSlot, stream: MediaStream): void {
if (this.closed || slot.failed || !slot.activated) {
return;
}
if (this.active === slot) {
const assignment = this.route.getMediaAssignment();
if (assignment?.upstream.kind === "sfu") {
this.events.onSfuStream(stream, assignment, false);
}
return;
}
const token = slot.activationToken;
const assignment = this.route.getActiveAssignment();
if (
this.pending !== slot ||
!token ||
!this.route.owns(token, "active") ||
assignment?.upstream.kind !== "sfu"
) {
return;
}
this.pending = null;
const previous = this.active;
this.active = slot;
if (!this.route.markMediaActive(token)) {
this.active = previous;
void disconnectSubscriber(slot.subscriber);
return;
}
this.recovery = null;
this.events.onSfuStream(stream, assignment, true);
if (previous && previous !== slot) {
try {
previous.subscriber.deactivate();
} catch {
// Disconnect remains the fail-closed cleanup path.
}
void disconnectSubscriber(previous.subscriber);
}
this.ready(token.revision, "active");
}
private commitMedia(token: RouteOperationToken, acknowledge: boolean): void {
if (!this.route.markMediaActive(token)) {
return;
}
this.recovery = null;
if (acknowledge) {
this.ready(token.revision, "active");
}
}
private clearPending(): void {
if (!this.pending) {
return;
}
const pending = this.pending;
this.pending = null;
void disconnectSubscriber(pending.subscriber);
}
private async retireActive(): Promise<void> {
if (!this.active) {
return;
}
const active = this.active;
this.active = null;
if (active.activated) {
try {
active.subscriber.deactivate();
} catch {
// Disconnect remains the fail-closed cleanup path.
}
}
await disconnectSubscriber(active.subscriber);
}
private handleFailure(slot: ViewerSubscriberSlot): void {
if (this.closed || slot.failed) {
return;
}
const wasActive = this.active === slot;
slot.failed = true;
if (this.pending === slot) {
this.pending = null;
}
if (wasActive) {
this.active = null;
}
const assignment = this.route.getPlannedAssignment();
const revision = this.route.getRevision();
const phase = this.route.getPhase();
const matchesPlannedRoute =
revision === slot.revision && assignment?.upstream.kind === "sfu";
if (revision === null || phase === null) {
return;
}
if (phase === "prepare") {
if (matchesPlannedRoute || wasActive) {
this.routeFailed(revision, "prepare");
}
return;
}
if (!matchesPlannedRoute) {
return;
}
this.requestRecovery(revision);
}
private requestRecovery(revision: number): void {
if (!this.recovery || this.recovery.revision !== revision) {
this.recovery = { revision, refreshed: false };
}
if (!this.recovery.refreshed) {
this.recovery.refreshed = true;
if (this.events.send({ type: "refresh-sfu", revision })) {
return;
}
}
this.routeFailed(revision, "active");
}
private routeFailed(revision: number, phase: MediaRoutePhase): void {
this.events.send({
type: "route-failed",
revision,
phase,
connectionId: null,
});
}
private ready(revision: number, phase: MediaRoutePhase): void {
this.events.send({ type: "route-ready", revision, phase });
}
}
async function disconnectSubscriber(
subscriber: ViewerSubscriberTransport,
): Promise<void> {
await subscriber.disconnect().catch(() => undefined);
}
+102 -23
View File
@@ -25,6 +25,7 @@ import {
} from "../components/StatusBadge";
import { StatsGrid } from "../components/StatsGrid";
import { ApiError, createRoom } from "../lib/api";
import { createOpaqueId } from "../lib/opaque-id";
import {
clearHostRoom,
getStableClientId,
@@ -40,6 +41,7 @@ import {
setVideoPaused,
type QualityProfileId,
} from "../media/quality";
import { HostSfuRoute } from "../media/host-sfu-route";
import type {
PeerSnapshot,
SignalConnectionState,
@@ -85,6 +87,7 @@ function closeAbandonedRoom(room: CreateRoomResponse): void {
role: "host",
token: room.hostToken,
clientId: getStableClientId("host", room.roomId),
shareGeneration: createOpaqueId(),
},
{
onMessage: () => undefined,
@@ -113,6 +116,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
const [qualityId, setQualityId] = useState<QualityProfileId>(
DEFAULT_QUALITY_PROFILE_ID,
);
const shareGenerationRef = useRef<string | null>(null);
const [phase, setPhase] = useState<HostPhase>("idle");
const [signalStatus, setSignalStatus] =
useState<SignalConnectionState>("offline");
@@ -143,6 +147,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
const qualityIdRef = useRef<QualityProfileId>(DEFAULT_QUALITY_PROFILE_ID);
const picturePausedRef = useRef(false);
const retiringStreamRef = useRef<MediaStream | null>(null);
const hostSfuRouteRef = useRef<HostSfuRoute | null>(null);
const viewers = useMemo(
() => Array.from(peerSnapshots.values()),
@@ -164,6 +169,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
signalRef.current?.stop();
peersRef.current.forEach((peer) => peer.dispose());
peersRef.current.clear();
void hostSfuRouteRef.current?.disconnect();
hostSfuRouteRef.current = null;
streamRef.current?.getTracks().forEach((track) => track.stop());
retiringStreamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
@@ -179,21 +186,59 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
);
}
function ensureHostSfuRoute(generation: number): HostSfuRoute {
const existing = hostSfuRouteRef.current;
if (existing) {
return existing;
}
let route: HostSfuRoute;
route = new HostSfuRoute({
getStream: () => streamRef.current,
getProfile: () => QUALITY_PROFILES[qualityIdRef.current],
reconcileChildren: (childPeerIds) => {
if (
isCurrentGeneration(generation) &&
hostSfuRouteRef.current === route
) {
reconcilePeerAssistedChildren(childPeerIds, generation);
}
},
send: (message) =>
isCurrentGeneration(generation) && hostSfuRouteRef.current === route
? signalRef.current?.send(message) === true
: false,
});
hostSfuRouteRef.current = route;
return route;
}
function clearHostSfuRoute(): void {
const route = hostSfuRouteRef.current;
hostSfuRouteRef.current = null;
void route?.disconnect();
}
function disposeResources(notifyServer: boolean): void {
sourceSwitchRef.current = null;
qualityChangeRef.current = null;
const signal = signalRef.current;
if (signal) {
if (notifyServer) {
signal.send({ type: "stop-sharing" });
const shareGeneration = shareGenerationRef.current;
if (shareGeneration) {
signal.send({ type: "stop-sharing", shareGeneration });
}
}
// Never carry a terminal message into a later authentication: the next
// sharing generation may already be reusing this room.
signal.stop();
}
signalRef.current = null;
shareGenerationRef.current = null;
peersRef.current.forEach((peer) => peer.dispose());
peersRef.current.clear();
void hostSfuRouteRef.current?.disconnect();
hostSfuRouteRef.current = null;
streamRef.current?.getTracks().forEach((track) => track.stop());
retiringStreamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
@@ -311,17 +356,20 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
qualityProfileId: nextId,
});
}
const results = await Promise.all(
[...peersRef.current.values()].map((peer) => peer.updateProfile(profile)),
);
const [results, sfuUpdated] = await Promise.all([
Promise.all(
[...peersRef.current.values()].map((peer) => peer.updateProfile(profile)),
),
hostSfuRouteRef.current?.updateProfile(profile) ?? Promise.resolve(true),
]);
if (
isCurrentGeneration(generation) &&
qualityChangeRef.current === token
) {
const failed = results.filter((updated) => !updated).length;
setNotice(
failed > 0
? `画质已切换,${failed} 位观众未能应用新的发送参数`
failed > 0 || !sfuUpdated
? "画质已切换,但部分观看连接未能应用新参数"
: `画质已切换为 ${profile.label}`,
);
}
@@ -520,13 +568,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
type: "set-quality-profile",
qualityProfileId: qualityIdRef.current,
});
reconcilePeerAssistedChildren(
message.mediaAssignment.childPeerIds,
generation,
void ensureHostSfuRoute(generation).resyncAuthoritative(
{
revision: message.routeRevision,
phase: "active",
assignment: message.routeAssignment,
},
);
return;
}
peerAssistedRef.current = false;
clearHostSfuRoute();
const currentViewerIds = new Set(message.viewerPeerIds);
for (const peerId of peersRef.current.keys()) {
if (!currentViewerIds.has(peerId)) {
@@ -535,8 +587,20 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
}
return;
}
if (message.type === "media-assignment") {
if (message.type === "route-update") {
if (peerAssistedRef.current) {
ensureHostSfuRoute(generation).accept(message);
}
return;
}
if (message.type === "sfu-config") {
if (peerAssistedRef.current) {
void ensureHostSfuRoute(generation).acceptConfig(message);
}
return;
}
if (message.type === "media-assignment") {
if (peerAssistedRef.current && !hostSfuRouteRef.current) {
reconcilePeerAssistedChildren(
message.mediaAssignment.childPeerIds,
generation,
@@ -621,8 +685,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
return;
}
const generation = generationRef.current + 1;
const shareGeneration = createOpaqueId();
generationRef.current = generation;
activeGenerationRef.current = generation;
shareGenerationRef.current = shareGeneration;
setNotice(null);
setCopied(false);
setPhase("starting");
@@ -636,6 +702,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
return;
}
activeGenerationRef.current = null;
shareGenerationRef.current = null;
setNotice(readableError(error));
setPhase("error");
return;
@@ -681,6 +748,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
role: "host",
token: activeRoom.hostToken,
clientId: getStableClientId("host", activeRoom.roomId),
shareGeneration,
},
{
onStatus: (status) => {
@@ -819,19 +887,23 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
watchCaptureEnd(captured, generation);
try {
const replacements = await Promise.all(
[...peersRef.current.entries()].map(async ([peerId, peer]) => {
try {
return {
peerId,
peer,
replaced: await peer.replaceStream(captured),
};
} catch {
return { peerId, peer, replaced: false };
}
}),
);
const activeSfuRoute = hostSfuRouteRef.current;
const [replacements, sfuReplaced] = await Promise.all([
Promise.all(
[...peersRef.current.entries()].map(async ([peerId, peer]) => {
try {
return {
peerId,
peer,
replaced: await peer.replaceStream(captured),
};
} catch {
return { peerId, peer, replaced: false };
}
}),
),
activeSfuRoute?.replaceStream(captured) ?? Promise.resolve(true),
]);
if (
!isCurrentGeneration(generation) ||
@@ -848,6 +920,13 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
failedPeerIds.push(peerId);
}
}
if (
!sfuReplaced &&
activeSfuRoute &&
hostSfuRouteRef.current === activeSfuRoute
) {
await activeSfuRoute.failActivePublisher();
}
previousStream.getTracks().forEach((track) => track.stop());
if (retiringStreamRef.current === previousStream) {
+178 -4
View File
@@ -27,6 +27,9 @@ import {
QUALITY_PROFILES,
type QualityProfileId,
} from "../media/quality";
import { relayCapacityMessageForBrowser } from "../media/relay-capability";
import { ViewerMessageAuthority } from "../media/viewer-message-authority";
import { ViewerSfuRoute } from "../media/viewer-sfu-route";
import type {
PeerSnapshot,
SignalConnectionState,
@@ -75,6 +78,12 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
childPeerIds: [],
};
let viewerRelay: ViewerRelay | null = null;
let viewerSfuRoute: ViewerSfuRoute | null = null;
let preparedParentPeerId: string | null = null;
let preparedParentSignals: Array<
Extract<ServerMessage, { type: "signal" }>
> = [];
const messageAuthority = new ViewerMessageAuthority();
const signal = new SignalingClient(
{
@@ -90,6 +99,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
},
onProtocolError: (message) => {
if (active) {
messageAuthority.invalidate();
setStatusText(message);
}
},
@@ -97,11 +107,14 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
if (!active) {
return;
}
messageAuthority.invalidate();
clearViewerSfuRoute();
clearPeerState();
setStatusText(message);
},
onAccessRequired: () => {
if (active) {
messageAuthority.invalidate();
onAuthorizationRequired();
}
},
@@ -109,7 +122,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
if (!active) {
return;
}
void handleMessage(message);
void handleMessage(message, messageAuthority.tokenFor(message));
},
},
);
@@ -147,6 +160,122 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
return viewerRelay;
}
function ensureViewerSfuRoute(): ViewerSfuRoute {
if (viewerSfuRoute) {
return viewerSfuRoute;
}
let route: ViewerSfuRoute;
route = new ViewerSfuRoute({
activatePeer: (assignment) => {
if (!active || viewerSfuRoute !== route) {
return;
}
applyMediaAssignment({
parentPeerId:
assignment.upstream.kind === "peer"
? assignment.upstream.peerId
: null,
childPeerIds: assignment.childPeerIds,
});
if (assignment.upstream.kind === "peer") {
return drainPreparedParentSignals(assignment.upstream.peerId);
}
},
preparePeer: (assignment) => {
if (!active || viewerSfuRoute !== route) {
return;
}
prepareParent(
assignment?.upstream.kind === "peer"
? assignment.upstream.peerId
: null,
);
},
resetMedia: () => {
if (!active || viewerSfuRoute !== route) {
return;
}
currentAssignment = { parentPeerId: null, childPeerIds: [] };
clearPeerState();
viewerRelay?.setChild(null);
},
reconcileSfuChildren: (childPeerIds) => {
if (!active || viewerSfuRoute !== route) {
return;
}
currentAssignment = limitMediaAssignment(
{
parentPeerId: currentAssignment.parentPeerId,
childPeerIds,
},
MAX_VIEWER_MEDIA_CHILDREN,
);
ensureViewerRelay()?.setChild(
currentAssignment.childPeerIds[0] ?? null,
);
},
onSfuStream: (nextStream, assignment, initialVideoStream) => {
if (!active || viewerSfuRoute !== route) {
return;
}
currentAssignment = limitMediaAssignment(
{ parentPeerId: null, childPeerIds: assignment.childPeerIds },
MAX_VIEWER_MEDIA_CHILDREN,
);
const relay = ensureViewerRelay();
relay?.setChild(currentAssignment.childPeerIds[0] ?? null);
relay?.setStream(nextStream);
setRemoteStream(nextStream);
setStatusText("正在播放");
if (initialVideoStream) {
peerRef.current?.dispose();
peerRef.current = null;
setPeerSnapshot(null);
}
},
send: (message) =>
active && viewerSfuRoute === route ? signal.send(message) : false,
});
viewerSfuRoute = route;
return route;
}
function clearViewerSfuRoute(): void {
const route = viewerSfuRoute;
viewerSfuRoute = null;
prepareParent(null);
void route?.disconnect();
}
function prepareParent(parentPeerId: string | null): void {
if (preparedParentPeerId === parentPeerId) {
return;
}
preparedParentPeerId = parentPeerId;
preparedParentSignals = [];
}
async function drainPreparedParentSignals(
parentPeerId: string,
): Promise<void> {
while (
active &&
peerAssisted &&
preparedParentPeerId === parentPeerId
) {
const message = preparedParentSignals.shift();
if (!message) {
preparedParentPeerId = null;
return;
}
const peer = ensurePeer();
if (!peer) {
return;
}
await peer.acceptSignal(message.fromPeerId, message.payload);
}
}
function clearUpstreamState(): void {
peerRef.current?.dispose();
peerRef.current = null;
@@ -232,6 +361,8 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
}
}
},
onRecoveryExhausted: (parentPeerId, connectionId) =>
viewerSfuRoute?.reportPeerFailure(parentPeerId, connectionId) ?? true,
},
forceRelay,
);
@@ -239,17 +370,25 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
return peer;
}
async function handleMessage(message: ServerMessage): Promise<void> {
async function handleMessage(
message: ServerMessage,
authorityToken: number,
): Promise<void> {
if (message.type === "authenticated") {
const nextPeerAssisted =
"mediaMode" in message && message.mediaMode === "peer-assisted";
if (!nextPeerAssisted && peerAssisted) {
clearViewerSfuRoute();
clearPeerState();
viewerRelay?.dispose();
viewerRelay = null;
currentAssignment = { parentPeerId: null, childPeerIds: [] };
}
peerAssisted = nextPeerAssisted;
const relayCapacity = relayCapacityMessageForBrowser(nextPeerAssisted);
if (relayCapacity) {
signal.send(relayCapacity);
}
currentIceConfig = message.iceConfig;
currentHostOnline = message.hostOnline;
setRelayAvailable(message.iceConfig.relayAvailable);
@@ -259,7 +398,16 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
void viewerRelay?.updateProfile(
QUALITY_PROFILES[currentQualityProfileId],
);
applyMediaAssignment(message.mediaAssignment);
await ensureViewerSfuRoute().resyncAuthoritative(
{
revision: message.routeRevision,
phase: "active",
assignment: message.routeAssignment,
},
);
if (!active || !messageAuthority.owns(authorityToken)) {
return;
}
}
if (
!message.hostOnline &&
@@ -298,8 +446,20 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
}
return;
}
if (message.type === "media-assignment") {
if (message.type === "route-update") {
if (peerAssisted) {
ensureViewerSfuRoute().accept(message);
}
return;
}
if (message.type === "sfu-config") {
if (peerAssisted) {
await ensureViewerSfuRoute().acceptConfig(message);
}
return;
}
if (message.type === "media-assignment") {
if (peerAssisted && !viewerSfuRoute) {
applyMediaAssignment(message.mediaAssignment);
}
return;
@@ -314,6 +474,13 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
return;
}
if (message.type === "signal") {
if (
peerAssisted &&
preparedParentPeerId === message.fromPeerId
) {
preparedParentSignals.push(message);
return;
}
if (
peerAssisted &&
currentAssignment.childPeerIds[0] === message.fromPeerId
@@ -367,12 +534,14 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
}
if (message.type === "sharing-stopped") {
currentHostOnline = false;
clearViewerSfuRoute();
clearPeerState();
setHostOnline(false);
setStatusText("分享已停止,等待分享者再次开始");
return;
}
if (message.type === "room-closed") {
clearViewerSfuRoute();
clearPeerState();
setStatusText(message.reason === "expired" ? "房间已过期" : "分享已结束");
signal.stop();
@@ -392,6 +561,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
"ROOM_FULL",
].includes(message.code)
) {
clearViewerSfuRoute();
clearPeerState();
}
setStatusText(message.message);
@@ -402,6 +572,10 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
return () => {
active = false;
signal.stop();
void viewerSfuRoute?.disconnect();
viewerSfuRoute = null;
preparedParentPeerId = null;
preparedParentSignals = [];
peerRef.current?.dispose();
peerRef.current = null;
viewerRelay?.dispose();
+481
View File
@@ -0,0 +1,481 @@
import type {
LocalTrackPublication,
Room,
Track,
TrackPublishOptions,
} from "livekit-client";
import {
configureVideoSender,
type QualityProfile,
} from "../media/quality";
export interface SfuConnectionConfig {
url: string;
token: string;
}
interface PublisherEvents {
onDisconnected?: () => void;
}
interface PublishedTrack {
publication: LocalTrackPublication;
rawTrack: MediaStreamTrack;
}
type LiveKit = typeof import("livekit-client");
type PublisherState =
| "idle"
| "connecting"
| "prepared"
| "active"
| "disconnected";
const roomDisconnects = new WeakMap<Room, Promise<void>>();
export class SfuPublisher {
private room: Room | null = null;
private sdk: LiveKit | null = null;
private video: PublishedTrack | null = null;
private audio: PublishedTrack | null = null;
private profile: QualityProfile | null = null;
private state: PublisherState = "idle";
private generation = 0;
private operationTail: Promise<void> = Promise.resolve();
private terminalNotified = false;
constructor(private readonly events: PublisherEvents = {}) {}
async connect(config: SfuConnectionConfig): Promise<boolean> {
if (this.state !== "idle") {
throw new Error("SFU publisher cannot be connected twice");
}
const generation = ++this.generation;
this.state = "connecting";
try {
const sdk = await import("livekit-client");
if (!this.ownsGeneration(generation)) {
return false;
}
const room = new sdk.Room();
this.room = room;
this.sdk = sdk;
room.on(sdk.RoomEvent.Disconnected, () => {
if (this.owns(room, generation)) {
const notify = this.state !== "connecting";
this.invalidate();
if (notify) {
this.notifyTerminalDisconnect();
}
}
});
await room.connect(config.url, config.token, { autoSubscribe: false });
if (!this.owns(room, generation)) {
await safeDisconnect(room);
return false;
}
this.state = "prepared";
return true;
} catch (error) {
if (!this.ownsGeneration(generation)) {
return false;
}
const room = this.invalidate();
if (room) {
await safeDisconnect(room);
}
throw error;
}
}
activate(stream: MediaStream, profile: QualityProfile): Promise<boolean> {
const generation = this.generation;
return this.enqueue(async () => {
const room = this.requireOwnedRoom(generation, "prepared");
if (!room) {
return false;
}
const sdk = this.sdk;
if (!sdk) {
throw new Error("SFU publisher SDK is unavailable");
}
const videoTrack = requiredVideoTrack(stream);
const audioTrack = stream.getAudioTracks()[0] ?? null;
try {
const video = await publishTrack(
room,
videoTrack,
sdk.Track.Source.ScreenShare,
videoPublishOptions(profile),
);
if (!this.owns(room, generation)) {
return false;
}
let audio: PublishedTrack | null = null;
if (audioTrack) {
audio = await publishTrack(
room,
audioTrack,
sdk.Track.Source.ScreenShareAudio,
{ dtx: false },
);
if (!this.owns(room, generation)) {
return false;
}
}
this.video = video;
this.audio = audio;
this.profile = profile;
this.state = "active";
return true;
} catch (error) {
if (this.owns(room, generation)) {
await this.failClosed(room, generation);
}
throw error;
}
});
}
deactivate(): Promise<boolean> {
const generation = this.generation;
return this.enqueue(async () => {
const room = this.requireOwnedRoom(generation, ["prepared", "active"]);
if (!room) {
return false;
}
if (this.state === "prepared") {
return true;
}
const video = this.video;
const audio = this.audio;
try {
if (audio) {
await unpublishTrack(room, audio);
if (!this.owns(room, generation)) {
return false;
}
}
if (video) {
await unpublishTrack(room, video);
if (!this.owns(room, generation)) {
return false;
}
}
} catch (error) {
if (this.owns(room, generation)) {
await this.failClosed(room, generation);
}
throw error;
}
this.video = null;
this.audio = null;
this.profile = null;
this.state = "prepared";
return true;
});
}
replaceStream(stream: MediaStream): Promise<boolean> {
const generation = this.generation;
return this.enqueue(async () => {
const room = this.requireOwnedRoom(generation, "active");
if (!room) {
return false;
}
const sdk = this.sdk;
const previousVideo = this.video;
if (!sdk || !previousVideo) {
throw new Error("SFU publisher has no active video publication");
}
const nextVideoTrack = requiredVideoTrack(stream);
const nextAudioTrack = stream.getAudioTracks()[0] ?? null;
const previousAudio = this.audio;
let videoReplaceAttempted = false;
let audioReplaceAttempted = false;
try {
videoReplaceAttempted = true;
await replacePublishedTrack(previousVideo, nextVideoTrack);
if (!this.owns(room, generation)) {
return false;
}
if (previousAudio && nextAudioTrack) {
audioReplaceAttempted = true;
await replacePublishedTrack(previousAudio, nextAudioTrack);
if (!this.owns(room, generation)) {
return false;
}
} else if (previousAudio) {
await unpublishTrack(room, previousAudio);
if (!this.owns(room, generation)) {
return false;
}
this.audio = null;
} else if (nextAudioTrack) {
const nextAudio = await publishTrack(
room,
nextAudioTrack,
sdk.Track.Source.ScreenShareAudio,
{ dtx: false },
);
if (!this.owns(room, generation)) {
return false;
}
this.audio = nextAudio;
}
previousVideo.rawTrack = nextVideoTrack;
if (previousAudio && nextAudioTrack) {
previousAudio.rawTrack = nextAudioTrack;
}
return true;
} catch (error) {
if (!this.owns(room, generation)) {
return false;
}
// A rejected publish/unpublish may have changed server state without
// returning enough ownership information to undo it safely.
if (
(previousAudio === null && nextAudioTrack !== null) ||
(previousAudio !== null && nextAudioTrack === null)
) {
await this.failClosed(room, generation);
throw error;
}
try {
if (audioReplaceAttempted && previousAudio) {
await replacePublishedTrack(previousAudio, previousAudio.rawTrack);
if (!this.owns(room, generation)) {
return false;
}
}
if (videoReplaceAttempted) {
await replacePublishedTrack(previousVideo, previousVideo.rawTrack);
if (!this.owns(room, generation)) {
return false;
}
}
return false;
} catch (rollbackError) {
if (this.owns(room, generation)) {
await this.failClosed(room, generation);
}
throw rollbackError;
}
}
});
}
updateProfile(profile: QualityProfile): Promise<boolean> {
const generation = this.generation;
return this.enqueue(async () => {
const room = this.requireOwnedRoom(generation, "active");
if (!room) {
return false;
}
const video = this.video;
const previousProfile = this.profile;
if (!video || !previousProfile) {
throw new Error("SFU publisher has no active video publication");
}
try {
await configurePublishedVideo(video, profile);
if (!this.owns(room, generation)) {
return false;
}
this.profile = profile;
return true;
} catch (error) {
if (!this.owns(room, generation)) {
return false;
}
try {
await configurePublishedVideo(video, previousProfile);
if (!this.owns(room, generation)) {
return false;
}
return false;
} catch (rollbackError) {
if (this.owns(room, generation)) {
await this.failClosed(room, generation);
}
throw rollbackError;
}
}
});
}
async disconnect(): Promise<void> {
const room = this.invalidate();
if (room) {
await safeDisconnect(room);
}
}
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.operationTail.then(operation, operation);
this.operationTail = result.then(
() => undefined,
() => undefined,
);
return result;
}
private requireOwnedRoom(
generation: number,
expectedState: PublisherState | PublisherState[],
): Room | null {
if (!this.ownsGeneration(generation)) {
return null;
}
const expected = Array.isArray(expectedState)
? expectedState
: [expectedState];
if (!expected.includes(this.state)) {
throw new Error(`SFU publisher is ${this.state}, expected ${expected.join(" or ")}`);
}
if (!this.room) {
throw new Error("SFU publisher room is unavailable");
}
return this.room;
}
private ownsGeneration(generation: number): boolean {
return this.generation === generation && this.state !== "disconnected";
}
private owns(room: Room, generation: number): boolean {
return this.room === room && this.ownsGeneration(generation);
}
private invalidate(): Room | null {
if (this.state === "disconnected") {
return null;
}
++this.generation;
this.state = "disconnected";
const room = this.room;
this.room = null;
this.sdk = null;
this.video = null;
this.audio = null;
this.profile = null;
return room;
}
private async failClosed(room: Room, generation: number): Promise<void> {
if (!this.owns(room, generation)) {
return;
}
this.invalidate();
await safeDisconnect(room);
this.notifyTerminalDisconnect();
}
private notifyTerminalDisconnect(): void {
if (this.terminalNotified) {
return;
}
this.terminalNotified = true;
this.events.onDisconnected?.();
}
}
async function publishTrack(
room: Room,
rawTrack: MediaStreamTrack,
source: Track.Source,
options: TrackPublishOptions,
): Promise<PublishedTrack> {
const publication = await room.localParticipant.publishTrack(rawTrack, {
source,
...options,
});
if (!publication.track) {
throw new Error("SFU did not retain the published track");
}
return { publication, rawTrack };
}
async function replacePublishedTrack(
published: PublishedTrack,
nextTrack: MediaStreamTrack,
): Promise<void> {
const localTrack = published.publication.track;
if (!localTrack) {
throw new Error("SFU publication has no local track");
}
await localTrack.replaceTrack(nextTrack);
}
async function unpublishTrack(room: Room, published: PublishedTrack): Promise<void> {
const localTrack = published.publication.track;
if (!localTrack) {
throw new Error("SFU publication has no local track");
}
await room.localParticipant.unpublishTrack(localTrack, false);
}
async function configurePublishedVideo(
published: PublishedTrack,
profile: QualityProfile,
): Promise<void> {
const videoTrack = published.publication.videoTrack;
const sender = videoTrack?.sender;
if (!sender) {
throw new Error("SFU video publication has no RTP sender");
}
await configureVideoSender(sender, profile);
videoTrack.publishOptions = {
...videoTrack.publishOptions,
...videoPublishOptions(profile),
};
}
function videoPublishOptions(profile: QualityProfile): TrackPublishOptions {
return {
simulcast: false,
screenShareEncoding: {
maxBitrate: profile.maxBitrate,
maxFramerate: profile.frameRate,
},
degradationPreference: "balanced",
};
}
async function safeDisconnect(room: Room): Promise<void> {
const existing = roomDisconnects.get(room);
if (existing) {
await existing;
return;
}
const disconnecting = room.disconnect(false).catch(() => undefined);
roomDisconnects.set(room, disconnecting);
await disconnecting;
}
function requiredVideoTrack(stream: MediaStream): MediaStreamTrack {
const track = stream.getVideoTracks()[0];
if (!track) {
throw new Error("屏幕共享没有视频轨道");
}
return track;
}
+355
View File
@@ -0,0 +1,355 @@
import type {
RemoteParticipant,
RemoteTrack,
RemoteTrackPublication,
Room,
Track,
} from "livekit-client";
import type { SfuConnectionConfig } from "./publisher";
interface SubscriberEvents {
onStream: (stream: MediaStream | null) => void;
onDisconnected?: () => void;
}
interface SubscribedTrack {
sid: string;
track: MediaStreamTrack;
}
type SubscriberState =
| "idle"
| "connecting"
| "prepared"
| "active"
| "disconnected";
type LiveKit = typeof import("livekit-client");
const HOST_IDENTITY = "host";
const roomDisconnects = new WeakMap<Room, Promise<void>>();
export class SfuSubscriber {
private room: Room | null = null;
private sdk: LiveKit | null = null;
private video: SubscribedTrack | null = null;
private audio: SubscribedTrack | null = null;
private readonly desiredTrackSids = new Set<string>();
private state: SubscriberState = "idle";
private generation = 0;
private terminalNotified = false;
constructor(private readonly events: SubscriberEvents) {}
async connect(config: SfuConnectionConfig): Promise<boolean> {
if (this.state !== "idle") {
throw new Error("SFU subscriber cannot be connected twice");
}
const generation = ++this.generation;
this.state = "connecting";
try {
const sdk = await import("livekit-client");
if (!this.ownsGeneration(generation)) {
return false;
}
const room = new sdk.Room();
this.room = room;
this.sdk = sdk;
this.bindRoomEvents(room, sdk, generation);
await room.connect(config.url, config.token, { autoSubscribe: false });
if (!this.owns(room, generation)) {
await safeDisconnect(room);
return false;
}
this.state = "prepared";
return true;
} catch (error) {
if (!this.ownsGeneration(generation)) {
return false;
}
const room = this.invalidate(false);
if (room) {
await safeDisconnect(room);
}
throw error;
}
}
activate(): boolean {
if (this.state === "active") {
return true;
}
if (this.state !== "prepared" || !this.room || !this.sdk) {
throw new Error(`SFU subscriber is ${this.state}, expected prepared`);
}
const room = this.room;
const generation = this.generation;
this.state = "active";
try {
const host = room.remoteParticipants.get(HOST_IDENTITY);
if (host) {
for (const publication of host.trackPublications.values()) {
this.subscribeIfAllowed(publication, host, this.sdk.Track);
}
}
return true;
} catch (error) {
if (this.owns(room, generation)) {
try {
this.rollbackSubscriptions(room);
this.state = "prepared";
this.clearMedia(true);
} catch {
void this.failClosed(room, generation);
}
}
throw error;
}
}
deactivate(): boolean {
if (this.state === "prepared") {
return true;
}
if (this.state !== "active" || !this.room) {
if (this.state === "disconnected") {
return false;
}
throw new Error(`SFU subscriber is ${this.state}, expected active`);
}
const room = this.room;
try {
this.rollbackSubscriptions(room);
} catch (error) {
void this.failClosed(room, this.generation);
throw error;
}
this.state = "prepared";
this.clearMedia(true);
return true;
}
async disconnect(): Promise<void> {
const room = this.invalidate(true);
if (room) {
await safeDisconnect(room);
}
}
private bindRoomEvents(
room: Room,
sdk: LiveKit,
generation: number,
): void {
room.on(
sdk.RoomEvent.TrackPublished,
(publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if (this.state !== "active" || !this.owns(room, generation)) {
return;
}
try {
this.subscribeIfAllowed(publication, participant, sdk.Track);
} catch {
void this.failClosed(room, generation);
}
},
);
room.on(
sdk.RoomEvent.TrackSubscribed,
(
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant,
) => {
if (
this.state !== "active" ||
!this.owns(room, generation) ||
participant.identity !== HOST_IDENTITY ||
!this.desiredTrackSids.has(publication.trackSid)
) {
return;
}
this.addTrack(
publication.trackSid,
publication.source,
track.mediaStreamTrack,
sdk.Track,
);
},
);
room.on(
sdk.RoomEvent.TrackUnsubscribed,
(
_track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant,
) => {
if (this.owns(room, generation) && participant.identity === HOST_IDENTITY) {
this.removeTrack(publication.trackSid);
}
},
);
room.on(
sdk.RoomEvent.TrackUnpublished,
(publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if (this.owns(room, generation) && participant.identity === HOST_IDENTITY) {
this.desiredTrackSids.delete(publication.trackSid);
this.removeTrack(publication.trackSid);
}
},
);
room.on(
sdk.RoomEvent.ParticipantDisconnected,
(participant: RemoteParticipant) => {
if (this.owns(room, generation) && participant.identity === HOST_IDENTITY) {
this.desiredTrackSids.clear();
this.clearMedia(true);
}
},
);
room.on(sdk.RoomEvent.Disconnected, () => {
if (this.owns(room, generation)) {
const notify = this.state !== "connecting";
this.invalidate(true);
if (notify) {
this.notifyTerminalDisconnect();
}
}
});
}
private subscribeIfAllowed(
publication: RemoteTrackPublication,
participant: RemoteParticipant,
trackType: typeof Track,
): void {
if (
participant.identity !== HOST_IDENTITY ||
!isScreenSource(publication.source, trackType)
) {
return;
}
this.desiredTrackSids.add(publication.trackSid);
publication.setSubscribed(true);
}
private rollbackSubscriptions(room: Room): void {
const host = room.remoteParticipants.get(HOST_IDENTITY);
for (const publication of host?.trackPublications.values() ?? []) {
if (this.desiredTrackSids.has(publication.trackSid)) {
publication.setSubscribed(false);
}
}
this.desiredTrackSids.clear();
}
private addTrack(
sid: string,
source: Track.Source,
track: MediaStreamTrack,
trackType: typeof Track,
): void {
if (source === trackType.Source.ScreenShare) {
this.video = { sid, track };
} else if (source === trackType.Source.ScreenShareAudio) {
this.audio = { sid, track };
} else {
return;
}
this.emitStream();
}
private removeTrack(sid: string): void {
if (this.video?.sid === sid) {
this.video = null;
}
if (this.audio?.sid === sid) {
this.audio = null;
}
this.emitStream();
}
private emitStream(): void {
if (!this.video) {
return;
}
const tracks = [this.video.track];
if (this.audio) {
tracks.push(this.audio.track);
}
this.events.onStream(new MediaStream(tracks));
}
private clearMedia(notify: boolean): void {
const hadVideo = this.video !== null;
this.video = null;
this.audio = null;
if (notify && hadVideo) {
this.events.onStream(null);
}
}
private ownsGeneration(generation: number): boolean {
return this.generation === generation && this.state !== "disconnected";
}
private owns(room: Room, generation: number): boolean {
return this.room === room && this.ownsGeneration(generation);
}
private invalidate(notifyStream: boolean): Room | null {
if (this.state === "disconnected") {
return null;
}
++this.generation;
this.state = "disconnected";
const room = this.room;
this.room = null;
this.sdk = null;
this.desiredTrackSids.clear();
this.clearMedia(notifyStream);
return room;
}
private async failClosed(room: Room, generation: number): Promise<void> {
if (!this.owns(room, generation)) {
return;
}
this.invalidate(true);
await safeDisconnect(room);
this.notifyTerminalDisconnect();
}
private notifyTerminalDisconnect(): void {
if (this.terminalNotified) {
return;
}
this.terminalNotified = true;
this.events.onDisconnected?.();
}
}
function isScreenSource(source: Track.Source, trackType: typeof Track): boolean {
return (
source === trackType.Source.ScreenShare ||
source === trackType.Source.ScreenShareAudio
);
}
async function safeDisconnect(room: Room): Promise<void> {
const existing = roomDisconnects.get(room);
if (existing) {
await existing;
return;
}
const disconnecting = room.disconnect(false).catch(() => undefined);
roomDisconnects.set(room, disconnecting);
await disconnecting;
}
+101 -12
View File
@@ -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) {
+18
View File
@@ -7,6 +7,7 @@ import type { ViteDevServer } from "vite";
import type { CreateRoomResponse } from "../shared/protocol.js";
import { AccessSession } from "./access-session.js";
import { loadConfig, type ServerConfig } from "./config.js";
import type { SfuTokenIssuer } from "./livekit-token.js";
import { RoomDatabase } from "./room-database.js";
import { RoomStore, RoomStoreError } from "./room-store.js";
import { SignalingServer } from "./signaling.js";
@@ -24,6 +25,7 @@ export interface CreateServerOptions {
maxSignalConnections?: number;
maxUnauthenticatedSignalConnections?: number;
accessSessionTtlSeconds?: number;
sfuTokenIssuer?: SfuTokenIssuer;
}
export interface ScreenerServer {
@@ -40,6 +42,21 @@ export async function createScreenerServer(
): Promise<ScreenerServer> {
const config = options.config ?? loadConfig();
const now = options.now ?? Date.now;
const livekitFallback = config.livekitFallback;
const sfuFallback = livekitFallback
? {
url: livekitFallback.url,
tokenIssuer:
options.sfuTokenIssuer ??
new (await import("./livekit-token.js")).LiveKitTokenIssuer({
apiKey: livekitFallback.apiKey,
apiSecret: livekitFallback.apiSecret,
maxViewersPerRoom: config.maxViewersPerRoom,
maxSfuRootsPerRoom: livekitFallback.maxSfuRootsPerRoom,
}),
maxRoots: livekitFallback.maxSfuRootsPerRoom,
}
: undefined;
const roomStore =
options.roomStore ??
new RoomStore({
@@ -94,6 +111,7 @@ export async function createScreenerServer(
server: httpServer,
roomStore,
peerAssistedMedia: config.peerAssistedMedia,
...(sfuFallback ? { sfuFallback } : {}),
ice: iceOptions,
allowedOrigins: config.allowedOrigins,
authorizeUpgrade: (request) =>
+86
View File
@@ -5,10 +5,20 @@ export type RuntimeEnvironment = "development" | "test" | "production";
const MAX_TURN_CREDENTIAL_TTL_SECONDS = 3_600;
const MAX_ACCESS_PASSWORD_BYTES = 128;
const MIN_TURN_SECRET_BYTES = 32;
const MIN_LIVEKIT_API_SECRET_BYTES = 32;
const MAX_PEER_ASSISTED_VIEWERS = 8;
const DEFAULT_MAX_VIEWERS_PER_ROOM = 8;
const DEFAULT_MAX_SFU_ROOTS_PER_ROOM = 2;
const MAX_SFU_ROOTS_PER_ROOM = 2;
const VISIBLE_ASCII_PATTERN = /^[\x21-\x7e]+$/;
export interface LiveKitFallbackConfig {
url: string;
apiKey: string;
apiSecret: string;
maxSfuRootsPerRoom: number;
}
export interface ServerConfig {
nodeEnv: RuntimeEnvironment;
port: number;
@@ -21,6 +31,7 @@ export interface ServerConfig {
maxRooms: number;
maxViewersPerRoom: number;
peerAssistedMedia: boolean;
livekitFallback?: LiveKitFallbackConfig;
stunUrls: readonly string[];
turnUrls: readonly string[];
turnSharedSecret?: string;
@@ -88,6 +99,65 @@ function parseEnvironment(value: string | undefined): RuntimeEnvironment {
return environment;
}
function parseLiveKitFallback(
environment: NodeJS.ProcessEnv,
nodeEnv: RuntimeEnvironment,
): LiveKitFallbackConfig | undefined {
const url = environment.LIVEKIT_URL?.trim() || undefined;
const apiKey = environment.LIVEKIT_API_KEY?.trim() || undefined;
const apiSecret = environment.LIVEKIT_API_SECRET?.trim() || undefined;
const configuredValues = [url, apiKey, apiSecret].filter(Boolean).length;
if (configuredValues === 0) {
return undefined;
}
if (configuredValues !== 3) {
throw new Error(
"LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must be configured together",
);
}
if (Buffer.byteLength(apiSecret!) < MIN_LIVEKIT_API_SECRET_BYTES) {
throw new Error("LIVEKIT_API_SECRET must contain at least 32 bytes");
}
let parsedUrl: URL;
try {
parsedUrl = new URL(url!);
} catch {
throw new Error("LIVEKIT_URL must be a valid ws or wss origin");
}
if (parsedUrl.protocol !== "ws:" && parsedUrl.protocol !== "wss:") {
throw new Error("LIVEKIT_URL must use ws or wss");
}
if (
parsedUrl.username ||
parsedUrl.password ||
parsedUrl.pathname !== "/" ||
parsedUrl.search ||
parsedUrl.hash
) {
throw new Error(
"LIVEKIT_URL must be an origin without credentials, path, query, or fragment",
);
}
if (nodeEnv === "production" && parsedUrl.protocol !== "wss:") {
throw new Error("LIVEKIT_URL must use wss in production");
}
return {
url: parsedUrl.origin,
apiKey: apiKey!,
apiSecret: apiSecret!,
maxSfuRootsPerRoom: parseBoundedInteger(
environment.MAX_SFU_ROOTS_PER_ROOM,
DEFAULT_MAX_SFU_ROOTS_PER_ROOM,
"MAX_SFU_ROOTS_PER_ROOM",
1,
MAX_SFU_ROOTS_PER_ROOM,
),
};
}
function parseUrlList(value: string | undefined, name: string): string[] {
if (!value?.trim()) {
return [];
@@ -287,12 +357,27 @@ export function loadConfig(
false,
"PEER_ASSISTED_MEDIA",
);
const livekitFallback = parseLiveKitFallback(environment, nodeEnv);
if (livekitFallback && !peerAssistedMedia) {
throw new Error("LiveKit fallback requires PEER_ASSISTED_MEDIA=true");
}
if ((turnUrls.length > 0) !== Boolean(turnSharedSecret)) {
throw new Error(
"TURN_URLS and TURN_SHARED_SECRET must either both be configured or both be absent",
);
}
const configuredSecrets = [
accessPassword,
turnSharedSecret,
livekitFallback?.apiKey,
livekitFallback?.apiSecret,
].filter((secret): secret is string => secret !== undefined);
if (new Set(configuredSecrets).size !== configuredSecrets.length) {
throw new Error(
"ACCESS_PASSWORD, TURN_SHARED_SECRET, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values",
);
}
if (
accessPassword &&
(!VISIBLE_ASCII_PATTERN.test(accessPassword) ||
@@ -347,6 +432,7 @@ export function loadConfig(
maxRooms: parsePositiveInteger(environment.MAX_ROOMS, 1_000, "MAX_ROOMS"),
maxViewersPerRoom,
peerAssistedMedia,
livekitFallback,
stunUrls,
turnUrls,
turnSharedSecret,
File diff suppressed because it is too large. Load diff
+129
View File
@@ -0,0 +1,129 @@
import {
AccessToken,
RoomConfiguration,
TrackSource,
} from "livekit-server-sdk";
import {
MAX_VIEWERS_PER_ROOM_LIMIT,
type Role,
} from "../shared/protocol.js";
const LIVEKIT_TOKEN_TTL_SECONDS = 5 * 60;
const MAX_SFU_ROOTS_PER_ROOM_LIMIT = 2;
const ROOM_ID_PATTERN = /^[1-9]\d{0,11}$/;
const OPAQUE_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
export interface SfuTokenRequest {
roomId: string;
role: Role;
peerId: string;
publicationGeneration: string;
allowlistedRootPeerIds: readonly string[];
}
export interface SfuTokenIssuer {
issueToken(request: SfuTokenRequest): Promise<string>;
}
export interface LiveKitTokenIssuerOptions {
apiKey: string;
apiSecret: string;
maxViewersPerRoom: number;
maxSfuRootsPerRoom: number;
}
export class LiveKitTokenIssuer implements SfuTokenIssuer {
constructor(private readonly options: LiveKitTokenIssuerOptions) {
if (!options.apiKey || Buffer.byteLength(options.apiSecret) < 32) {
throw new Error("LiveKit API credentials are invalid");
}
if (
!Number.isSafeInteger(options.maxViewersPerRoom) ||
options.maxViewersPerRoom < 1 ||
options.maxViewersPerRoom > MAX_VIEWERS_PER_ROOM_LIMIT
) {
throw new Error("LiveKit viewer limit is invalid");
}
if (
!Number.isSafeInteger(options.maxSfuRootsPerRoom) ||
options.maxSfuRootsPerRoom < 1 ||
options.maxSfuRootsPerRoom > MAX_SFU_ROOTS_PER_ROOM_LIMIT
) {
throw new Error("LiveKit SFU root limit is invalid");
}
}
async issueToken(request: SfuTokenRequest): Promise<string> {
validateTokenRequest(request, this.options.maxSfuRootsPerRoom);
const isHost = request.role === "host";
const room = liveKitRoomName(
request.roomId,
request.publicationGeneration,
);
const token = new AccessToken(
this.options.apiKey,
this.options.apiSecret,
{
identity: isHost ? "host" : `viewer:${request.peerId}`,
ttl: LIVEKIT_TOKEN_TTL_SECONDS,
},
);
token.addGrant({
roomJoin: true,
room,
canPublish: isHost,
canPublishSources: isHost
? [TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO]
: undefined,
canSubscribe: !isHost,
canPublishData: false,
canUpdateOwnMetadata: false,
});
token.roomConfig = new RoomConfiguration({
name: room,
maxParticipants: this.options.maxViewersPerRoom + 1,
});
return await token.toJwt();
}
}
function validateTokenRequest(
request: SfuTokenRequest,
maxSfuRootsPerRoom: number,
): void {
if (!ROOM_ID_PATTERN.test(request.roomId)) {
throw new Error("LiveKit room ID is invalid");
}
if (
!OPAQUE_ID_PATTERN.test(request.peerId) ||
!OPAQUE_ID_PATTERN.test(request.publicationGeneration)
) {
throw new Error("LiveKit participant identity is invalid");
}
if (
request.allowlistedRootPeerIds.length > maxSfuRootsPerRoom ||
request.allowlistedRootPeerIds.some(
(peerId) => !OPAQUE_ID_PATTERN.test(peerId),
)
) {
throw new Error("LiveKit SFU root allowlist is invalid");
}
const roots = new Set(request.allowlistedRootPeerIds);
if (roots.size !== request.allowlistedRootPeerIds.length) {
throw new Error("LiveKit SFU root allowlist contains duplicates");
}
if (request.role === "viewer" && !roots.has(request.peerId)) {
throw new Error("LiveKit viewer is not an allowlisted SFU root");
}
}
function liveKitRoomName(
roomId: string,
publicationGeneration: string,
): string {
return `screener-${roomId}-${publicationGeneration}`;
}
+425
View File
@@ -0,0 +1,425 @@
import {
MAX_MEDIA_ROUTE_REVISION,
MAX_VIEWERS_PER_ROOM_LIMIT,
participantRouteAssignmentSchema,
sfuPublicationGenerationSchema,
type MediaRoutePhase,
type ParticipantRouteAssignment,
} from "../shared/protocol.js";
const MAX_PARTICIPANTS_PER_ROOM = MAX_VIEWERS_PER_ROOM_LIMIT + 1;
export interface RoomSfuRoute {
publicationGeneration: string | null;
rootPeerIds: readonly string[];
}
export interface RoomMediaRoute {
revision: number;
assignments: ReadonlyMap<string, ParticipantRouteAssignment>;
sfu: RoomSfuRoute;
}
export interface PendingRoomMediaRoute {
route: RoomMediaRoute;
expectedParticipantIds: ReadonlySet<string>;
readyParticipantIds: ReadonlySet<string>;
}
export interface MediaRouteControllerOptions {
hostPeerId: string;
assignments: ReadonlyMap<string, ParticipantRouteAssignment>;
revision?: number;
sfuPublicationGeneration?: string | null;
sfuRootPeerIds?: readonly string[];
}
export interface PrepareMediaRouteInput {
assignments: ReadonlyMap<string, ParticipantRouteAssignment>;
expectedParticipantIds: ReadonlySet<string>;
sfuPublicationGeneration?: string | null;
sfuRootPeerIds?: readonly string[];
}
export interface ReconcileBaselineInput {
assignments: ReadonlyMap<string, ParticipantRouteAssignment>;
sfuPublicationGeneration?: string | null;
sfuRootPeerIds?: readonly string[];
}
interface StoredPendingRoute {
route: RoomMediaRoute;
expectedParticipantIds: Set<string>;
readyParticipantIds: Set<string>;
}
export class MediaRouteController {
private activeRoute: RoomMediaRoute;
private pendingRoute: StoredPendingRoute | undefined;
private latestRevision: number;
private readonly hostPeerId: string;
constructor(options: MediaRouteControllerOptions) {
this.hostPeerId = options.hostPeerId;
const revision = options.revision ?? 0;
assertRevision(revision);
this.activeRoute = createRoute(
revision,
options.assignments,
options.sfuPublicationGeneration ?? null,
options.sfuRootPeerIds ?? [],
this.hostPeerId,
);
this.latestRevision = revision;
}
getActiveRoute(): RoomMediaRoute {
return cloneRoute(this.activeRoute);
}
getPendingRoute(): PendingRoomMediaRoute | undefined {
const pending = this.pendingRoute;
return pending
? {
route: cloneRoute(pending.route),
expectedParticipantIds: new Set(pending.expectedParticipantIds),
readyParticipantIds: new Set(pending.readyParticipantIds),
}
: undefined;
}
reconcileBaseline(input: ReconcileBaselineInput): number | undefined {
if (this.pendingRoute) {
return undefined;
}
const revision = this.nextRevision();
const route = createRoute(
revision,
input.assignments,
input.sfuPublicationGeneration === undefined
? this.activeRoute.sfu.publicationGeneration
: input.sfuPublicationGeneration,
input.sfuRootPeerIds ?? this.activeRoute.sfu.rootPeerIds,
this.hostPeerId,
);
if (hasSameTopology(route, this.activeRoute)) {
return undefined;
}
this.activeRoute = route;
this.latestRevision = revision;
return revision;
}
prepare(input: PrepareMediaRouteInput): number | undefined {
if (this.pendingRoute) {
return undefined;
}
const revision = this.nextRevision();
const route = createRoute(
revision,
input.assignments,
input.sfuPublicationGeneration ?? null,
input.sfuRootPeerIds ?? [],
this.hostPeerId,
);
const expectedParticipantIds = new Set(input.expectedParticipantIds);
if (expectedParticipantIds.size > MAX_PARTICIPANTS_PER_ROOM) {
throw new Error("Too many expected media route participants");
}
for (const peerId of expectedParticipantIds) {
if (!route.assignments.has(peerId)) {
throw new Error(`Expected participant ${peerId} has no route assignment`);
}
}
this.latestRevision = revision;
this.pendingRoute = {
route,
expectedParticipantIds,
readyParticipantIds: new Set(),
};
return revision;
}
ready(peerId: string, revision: number, phase: MediaRoutePhase): boolean {
const pending = this.pendingRoute;
if (
phase !== "prepare" ||
!pending ||
pending.route.revision !== revision ||
!pending.expectedParticipantIds.has(peerId) ||
pending.readyParticipantIds.has(peerId)
) {
return false;
}
pending.readyParticipantIds.add(peerId);
return true;
}
commit(revision: number): boolean {
const pending = this.pendingRoute;
if (!pending || pending.route.revision !== revision) {
return false;
}
for (const peerId of pending.expectedParticipantIds) {
if (!pending.readyParticipantIds.has(peerId)) {
return false;
}
}
this.activeRoute = pending.route;
this.pendingRoute = undefined;
return true;
}
abort(revision: number): boolean {
const pending = this.pendingRoute;
if (!pending || pending.route.revision !== revision) {
return false;
}
const rollbackRevision = this.nextRevision();
this.activeRoute = cloneRoute(this.activeRoute, rollbackRevision);
this.latestRevision = rollbackRevision;
this.pendingRoute = undefined;
return true;
}
hostActiveMediaEdges(): number {
const edgeCount = hostMediaEdges(this.activeRoute, this.hostPeerId);
if (edgeCount > 2) {
throw new Error("Host active media edge budget exceeded");
}
return edgeCount;
}
private nextRevision(): number {
if (this.latestRevision >= MAX_MEDIA_ROUTE_REVISION) {
throw new Error("Media route revision space exhausted");
}
return this.latestRevision + 1;
}
}
function createRoute(
revision: number,
assignments: ReadonlyMap<string, ParticipantRouteAssignment>,
publicationGeneration: string | null,
rootPeerIds: readonly string[],
hostPeerId: string,
): RoomMediaRoute {
assertRevision(revision);
if (
assignments.size === 0 ||
assignments.size > MAX_PARTICIPANTS_PER_ROOM
) {
throw new Error("Media route participant count is outside room bounds");
}
if (publicationGeneration !== null) {
sfuPublicationGenerationSchema.parse(publicationGeneration);
}
const clonedAssignments = new Map<string, ParticipantRouteAssignment>();
for (const [peerId, assignment] of assignments) {
const parsed = participantRouteAssignmentSchema.parse(assignment);
clonedAssignments.set(peerId, cloneAssignment(parsed));
}
const route: RoomMediaRoute = {
revision,
assignments: clonedAssignments,
sfu: {
publicationGeneration,
rootPeerIds: [...rootPeerIds],
},
};
assertRouteInvariants(route, hostPeerId);
return route;
}
function assertRouteInvariants(
route: RoomMediaRoute,
hostPeerId: string,
): void {
const hostAssignment = route.assignments.get(hostPeerId);
if (!hostAssignment) {
throw new Error("Media route is missing its host assignment");
}
if (hostAssignment.upstream.kind !== "none") {
throw new Error("The host cannot have a media upstream");
}
if (
hostAssignment.sfuPublicationGeneration !==
route.sfu.publicationGeneration
) {
throw new Error("Host and room SFU publication generations differ");
}
const rootPeerIds = new Set(route.sfu.rootPeerIds);
if (
rootPeerIds.size !== route.sfu.rootPeerIds.length ||
rootPeerIds.size > MAX_VIEWERS_PER_ROOM_LIMIT
) {
throw new Error("SFU root participants must be unique and bounded");
}
if (
(route.sfu.publicationGeneration === null) !== (rootPeerIds.size === 0)
) {
throw new Error("SFU roots and publication generation must be active together");
}
for (const [peerId, assignment] of route.assignments) {
if (
peerId !== hostPeerId &&
assignment.sfuPublicationGeneration !== null
) {
throw new Error("Only the host may own an SFU publication generation");
}
if (assignment.childPeerIds.includes(peerId)) {
throw new Error("A participant cannot be its own media child");
}
for (const childPeerId of assignment.childPeerIds) {
const childAssignment = route.assignments.get(childPeerId);
if (!childAssignment) {
throw new Error(`Media child ${childPeerId} has no assignment`);
}
if (
childAssignment.upstream.kind !== "peer" ||
childAssignment.upstream.peerId !== peerId
) {
throw new Error("Peer media edges must be reciprocal");
}
}
if (assignment.upstream.kind === "peer") {
const parentAssignment = route.assignments.get(
assignment.upstream.peerId,
);
if (!parentAssignment?.childPeerIds.includes(peerId)) {
throw new Error("Peer media edges must be reciprocal");
}
}
if (assignment.upstream.kind === "sfu" && !rootPeerIds.has(peerId)) {
throw new Error("Every SFU upstream must belong to the root allowlist");
}
}
for (const rootPeerId of rootPeerIds) {
const rootAssignment = route.assignments.get(rootPeerId);
if (!rootAssignment || rootAssignment.upstream.kind !== "sfu") {
throw new Error("Every SFU root must have the SFU as its upstream");
}
}
assertAcyclicPeerEdges(route.assignments);
if (hostMediaEdges(route, hostPeerId) > 2) {
throw new Error("Host active media edge budget exceeded");
}
}
function assertAcyclicPeerEdges(
assignments: ReadonlyMap<string, ParticipantRouteAssignment>,
): void {
for (const startPeerId of assignments.keys()) {
const ancestors = new Set([startPeerId]);
let assignment = assignments.get(startPeerId);
while (assignment?.upstream.kind === "peer") {
const parentPeerId = assignment.upstream.peerId;
if (ancestors.has(parentPeerId)) {
throw new Error("Peer media route contains a cycle");
}
ancestors.add(parentPeerId);
assignment = assignments.get(parentPeerId);
}
}
}
function hostMediaEdges(route: RoomMediaRoute, hostPeerId: string): number {
const directChildren = route.assignments.get(hostPeerId)?.childPeerIds.length;
if (directChildren === undefined) {
throw new Error("Media route is missing its host assignment");
}
return directChildren + (route.sfu.publicationGeneration === null ? 0 : 1);
}
function hasSameTopology(left: RoomMediaRoute, right: RoomMediaRoute): boolean {
if (
left.sfu.publicationGeneration !== right.sfu.publicationGeneration ||
left.sfu.rootPeerIds.length !== right.sfu.rootPeerIds.length ||
left.sfu.rootPeerIds.some(
(peerId, index) => peerId !== right.sfu.rootPeerIds[index],
) ||
left.assignments.size !== right.assignments.size
) {
return false;
}
for (const [peerId, leftAssignment] of left.assignments) {
const rightAssignment = right.assignments.get(peerId);
if (
!rightAssignment ||
leftAssignment.sfuPublicationGeneration !==
rightAssignment.sfuPublicationGeneration ||
leftAssignment.upstream.kind !== rightAssignment.upstream.kind ||
(leftAssignment.upstream.kind === "peer" &&
(rightAssignment.upstream.kind !== "peer" ||
leftAssignment.upstream.peerId !==
rightAssignment.upstream.peerId)) ||
leftAssignment.childPeerIds.length !==
rightAssignment.childPeerIds.length ||
leftAssignment.childPeerIds.some(
(childPeerId, index) =>
childPeerId !== rightAssignment.childPeerIds[index],
)
) {
return false;
}
}
return true;
}
function cloneRoute(
route: RoomMediaRoute,
revision = route.revision,
): RoomMediaRoute {
return {
revision,
assignments: new Map(
[...route.assignments].map(([peerId, assignment]) => [
peerId,
cloneAssignment(assignment),
]),
),
sfu: {
publicationGeneration: route.sfu.publicationGeneration,
rootPeerIds: [...route.sfu.rootPeerIds],
},
};
}
function cloneAssignment(
assignment: ParticipantRouteAssignment,
): ParticipantRouteAssignment {
return {
upstream:
assignment.upstream.kind === "peer"
? { kind: "peer", peerId: assignment.upstream.peerId }
: { kind: assignment.upstream.kind },
childPeerIds: [...assignment.childPeerIds],
sfuPublicationGeneration: assignment.sfuPublicationGeneration,
};
}
function assertRevision(revision: number): void {
if (
!Number.isSafeInteger(revision) ||
revision < 0 ||
revision > MAX_MEDIA_ROUTE_REVISION
) {
throw new Error("Media route revision is outside protocol bounds");
}
}
+200 -8
View File
@@ -1,4 +1,7 @@
import type { MediaAssignment } from "../shared/protocol.js";
import type {
MediaAssignment,
RelayDownstreamEdges,
} from "../shared/protocol.js";
export interface MediaAssignmentChange {
peerId: string;
@@ -6,10 +9,13 @@ export interface MediaAssignmentChange {
mediaAssignment: MediaAssignment;
}
export const MAX_PEER_RELAY_DEPTH = 4;
interface RelayViewer {
order: number;
parentPeerId: string | null;
childPeerIds: string[];
downstreamEdges: RelayDownstreamEdges;
}
interface RelayRoom {
@@ -56,13 +62,17 @@ export class PeerRelayTopology {
): MediaAssignmentChange[] {
const room = this.room(roomId);
const before = snapshot(room);
if (!room.viewers.has(peerId)) {
const existingViewer = room.viewers.get(peerId);
if (!existingViewer) {
room.viewers.set(peerId, {
order: room.nextOrder,
parentPeerId: null,
childPeerIds: [],
downstreamEdges: 0,
});
room.nextOrder += 1;
} else {
existingViewer.downstreamEdges = 0;
}
for (const [candidatePeerId, viewer] of orderedViewers(room)) {
if (viewer.parentPeerId === null) {
@@ -72,6 +82,28 @@ export class PeerRelayTopology {
return changedAssignments(before, snapshot(room));
}
setViewerRelayCapacity(
roomId: string,
peerId: string,
downstreamEdges: RelayDownstreamEdges,
connectedPeerIds: ReadonlySet<string>,
): MediaAssignmentChange[] {
const room = this.rooms.get(roomId);
const viewer = room?.viewers.get(peerId);
if (!room || !viewer) {
return [];
}
const before = snapshot(room);
viewer.downstreamEdges = downstreamEdges;
for (const [candidatePeerId, candidate] of orderedViewers(room)) {
if (candidate.parentPeerId === null) {
this.assignViewer(room, candidatePeerId, connectedPeerIds);
}
}
return changedAssignments(before, snapshot(room));
}
removeViewer(
roomId: string,
peerId: string,
@@ -128,6 +160,62 @@ export class PeerRelayTopology {
: undefined;
}
getAssignments(roomId: string): Map<string, MediaAssignment> {
const room = this.rooms.get(roomId);
return room ? snapshot(room) : new Map();
}
getHostPeerId(roomId: string): string | undefined {
return this.rooms.get(roomId)?.hostPeerId;
}
reassignViewer(
roomId: string,
peerId: string,
connectedPeerIds: ReadonlySet<string>,
excludedParentPeerIds: ReadonlySet<string>,
maxDepth = MAX_PEER_RELAY_DEPTH,
): MediaAssignmentChange[] | undefined {
const room = this.rooms.get(roomId);
const viewer = room?.viewers.get(peerId);
if (
!room?.hostPeerId ||
!viewer ||
!connectedPeerIds.has(peerId) ||
!Number.isSafeInteger(maxDepth) ||
maxDepth < 1
) {
return undefined;
}
const subtreePeerIds = collectSubtreePeerIds(room, peerId);
const subtreeHeight = maximumSubtreeDepth(room, peerId);
if (maximumDepthOutsideSubtree(room, subtreePeerIds) > maxDepth) {
return undefined;
}
const parentPeerId = findReassignmentParent(
room,
connectedPeerIds,
excludedParentPeerIds,
subtreePeerIds,
maxDepth - subtreeHeight - 1,
);
if (!parentPeerId || parentPeerId === viewer.parentPeerId) {
return undefined;
}
const before = snapshot(room);
this.detach(room, peerId, viewer.parentPeerId);
viewer.parentPeerId = parentPeerId;
const children = childPeerIds(room, parentPeerId);
children.push(peerId);
children.sort(
(left, right) =>
room.viewers.get(left)!.order - room.viewers.get(right)!.order,
);
return changedAssignments(before, snapshot(room));
}
isParentOf(roomId: string, parentPeerId: string, childPeerId: string): boolean {
return (
this.rooms.get(roomId)?.viewers.get(childPeerId)?.parentPeerId ===
@@ -165,7 +253,11 @@ export class PeerRelayTopology {
) {
return;
}
const parentPeerId = findShallowestAvailableParent(room, connectedPeerIds);
const parentPeerId = findShallowestAvailableParent(
room,
connectedPeerIds,
MAX_PEER_RELAY_DEPTH - maximumSubtreeDepth(room, peerId) - 1,
);
if (!parentPeerId) {
return;
}
@@ -197,28 +289,128 @@ export class PeerRelayTopology {
function findShallowestAvailableParent(
room: RelayRoom,
connectedPeerIds: ReadonlySet<string>,
maximumParentDepth: number,
): string | undefined {
const hostPeerId = room.hostPeerId;
if (!hostPeerId || !connectedPeerIds.has(hostPeerId)) {
return undefined;
}
const queue = [hostPeerId];
const queue: Array<{ peerId: string; depth: number }> = [
{ peerId: hostPeerId, depth: 0 },
];
while (queue.length > 0) {
const peerId = queue.shift()!;
if (!connectedPeerIds.has(peerId)) {
const { peerId, depth } = queue.shift()!;
if (depth > maximumParentDepth || !connectedPeerIds.has(peerId)) {
continue;
}
const children = childPeerIds(room, peerId);
const capacity = peerId === hostPeerId ? 2 : 1;
const capacity = downstreamCapacity(room, peerId);
if (children.length < capacity) {
return peerId;
}
queue.push(...children);
for (const childPeerId of children) {
queue.push({ peerId: childPeerId, depth: depth + 1 });
}
}
return undefined;
}
function findReassignmentParent(
room: RelayRoom,
connectedPeerIds: ReadonlySet<string>,
excludedParentPeerIds: ReadonlySet<string>,
subtreePeerIds: ReadonlySet<string>,
maximumParentDepth: number,
): string | undefined {
const hostPeerId = room.hostPeerId;
if (!hostPeerId || !connectedPeerIds.has(hostPeerId)) {
return undefined;
}
const queue: Array<{ peerId: string; depth: number }> = [
{ peerId: hostPeerId, depth: 0 },
];
while (queue.length > 0) {
const { peerId, depth } = queue.shift()!;
if (depth > maximumParentDepth || !connectedPeerIds.has(peerId)) {
continue;
}
const children = childPeerIds(room, peerId);
const capacity = downstreamCapacity(room, peerId);
if (
children.length < capacity &&
!excludedParentPeerIds.has(peerId) &&
!subtreePeerIds.has(peerId)
) {
return peerId;
}
for (const childPeerId of children) {
queue.push({ peerId: childPeerId, depth: depth + 1 });
}
}
return undefined;
}
function downstreamCapacity(room: RelayRoom, peerId: string): number {
return room.hostPeerId === peerId
? 2
: (room.viewers.get(peerId)?.downstreamEdges ?? 0);
}
function collectSubtreePeerIds(room: RelayRoom, rootPeerId: string): Set<string> {
const peerIds = new Set<string>();
const queue = [rootPeerId];
while (queue.length > 0) {
const peerId = queue.shift()!;
if (peerIds.has(peerId)) {
continue;
}
peerIds.add(peerId);
queue.push(...(room.viewers.get(peerId)?.childPeerIds ?? []));
}
return peerIds;
}
function maximumSubtreeDepth(room: RelayRoom, rootPeerId: string): number {
let maximumDepth = 0;
const queue: Array<{ peerId: string; depth: number }> = [
{ peerId: rootPeerId, depth: 0 },
];
while (queue.length > 0) {
const { peerId, depth } = queue.shift()!;
maximumDepth = Math.max(maximumDepth, depth);
for (const childPeerId of room.viewers.get(peerId)?.childPeerIds ?? []) {
queue.push({ peerId: childPeerId, depth: depth + 1 });
}
}
return maximumDepth;
}
function maximumDepthOutsideSubtree(
room: RelayRoom,
excludedPeerIds: ReadonlySet<string>,
): number {
const hostPeerId = room.hostPeerId;
if (!hostPeerId) {
return 0;
}
let maximumDepth = 0;
const queue: Array<{ peerId: string; depth: number }> = [
{ peerId: hostPeerId, depth: 0 },
];
while (queue.length > 0) {
const { peerId, depth } = queue.shift()!;
maximumDepth = Math.max(maximumDepth, depth);
for (const childPeerId of childPeerIds(room, peerId)) {
if (!excludedPeerIds.has(childPeerId)) {
queue.push({ peerId: childPeerId, depth: depth + 1 });
}
}
}
return maximumDepth;
}
function childPeerIds(room: RelayRoom, peerId: string): string[] {
if (room.hostPeerId === peerId) {
return room.hostChildPeerIds;
+143 -133
View File
@@ -15,9 +15,9 @@ import {
} from "../shared/protocol.js";
import { RoomStore, RoomStoreError } from "./room-store.js";
import {
PeerRelayTopology,
type MediaAssignmentChange,
} from "./peer-relay-topology.js";
HybridMediaRouter,
type SfuFallbackOptions,
} from "./hybrid-media-router.js";
import { createIceConfig, type IceConfigOptions } from "./turn.js";
type ErrorCode = Extract<ServerMessage, { type: "error" }>["code"];
@@ -31,6 +31,7 @@ interface AuthenticatedSession {
role: Role;
peerId: string;
roomExpiresAtMs: number | null;
shareGeneration: string | null;
}
interface SocketState {
@@ -44,6 +45,7 @@ export interface SignalingOptions {
server: HttpServer;
roomStore: RoomStore;
peerAssistedMedia: boolean;
sfuFallback?: SfuFallbackOptions;
ice: IceConfigOptions;
allowedOrigins: ReadonlySet<string>;
authorizeUpgrade: (request: IncomingMessage) => boolean;
@@ -71,7 +73,8 @@ export class SignalingServer {
string,
QualityProfileId
>();
private readonly peerRelayTopology = new PeerRelayTopology();
private readonly shareGenerationsByRoom = new Map<string, string>();
private readonly hybridMediaRouter?: HybridMediaRouter;
private readonly now: () => number;
private readonly authenticationTimeoutMs: number;
private readonly viewerDisconnectGraceMs: number;
@@ -100,6 +103,25 @@ export class SignalingServer {
) {
throw new Error("WebSocket connection limits are invalid");
}
if (options.sfuFallback && !options.peerAssistedMedia) {
throw new Error("SFU fallback requires peer-assisted media");
}
if (options.peerAssistedMedia) {
this.hybridMediaRouter = new HybridMediaRouter({
roomStore: options.roomStore,
sfuFallback: options.sfuFallback,
sendToSession: (sessionId, message) =>
this.sendToSession(sessionId, message),
getConnectionId: (roomId, viewerPeerId) =>
this.connectionIdsByViewer.get(
viewerConnectionKey(roomId, viewerPeerId),
),
deleteConnectionId: (roomId, viewerPeerId) =>
this.connectionIdsByViewer.delete(
viewerConnectionKey(roomId, viewerPeerId),
),
});
}
this.upgradeHandler = (request, socket, head) => {
let requestUrl: URL;
@@ -157,6 +179,8 @@ export class SignalingServer {
clearTimeout(timer);
}
this.viewerGraceTimers.clear();
this.hybridMediaRouter?.close();
this.shareGenerationsByRoom.clear();
this.options.server.off("upgrade", this.upgradeHandler);
for (const socket of this.webSocketServer.clients) {
@@ -285,6 +309,21 @@ export class SignalingServer {
clearTimeout(state.authenticationTimer);
this.unauthenticatedConnections -= 1;
let shareGeneration: string | null = null;
if (participant.role === "host" && message.role === "host") {
const currentGeneration = this.shareGenerationsByRoom.get(
participant.roomId,
);
shareGeneration =
message.shareGeneration ?? currentGeneration ?? state.sessionId;
if (
currentGeneration !== undefined &&
currentGeneration !== shareGeneration
) {
this.stopSharing(participant.roomId);
}
this.shareGenerationsByRoom.set(participant.roomId, shareGeneration);
}
state.authenticated = {
roomId: participant.roomId,
role: participant.role,
@@ -293,29 +332,19 @@ export class SignalingServer {
participant.expiresAt === null
? null
: Date.parse(participant.expiresAt),
shareGeneration,
};
this.clearViewerGrace(participant.roomId, participant.peerId);
const assignmentChanges = this.options.peerAssistedMedia
? participant.role === "host"
? this.peerRelayTopology.setHost(
participant.roomId,
participant.peerId,
this.connectedPeerIds(participant.roomId),
)
: this.peerRelayTopology.addViewer(
participant.roomId,
participant.peerId,
this.connectedPeerIds(participant.roomId),
)
: [];
this.clearChangedConnectionIds(participant.roomId, assignmentChanges);
const mediaAssignment = this.options.peerAssistedMedia
? this.peerRelayTopology.getAssignment(
participant.roomId,
participant.peerId,
)
: undefined;
if (this.options.peerAssistedMedia && !mediaAssignment) {
const routeParticipant = {
roomId: participant.roomId,
role: participant.role,
peerId: participant.peerId,
sessionId: state.sessionId,
};
let hybridState;
try {
hybridState = this.hybridMediaRouter?.connectParticipant(routeParticipant);
} catch {
console.error("Peer relay topology did not assign an authenticated participant");
this.sendError(socket, "SERVER_ERROR", "Media assignment failed");
socket.close(1011, "Media assignment failed");
@@ -349,11 +378,13 @@ export class SignalingServer {
: Date.parse(participant.expiresAt),
),
};
if (mediaAssignment) {
if (hybridState) {
this.send(socket, {
...authenticatedMessage,
mediaMode: "peer-assisted",
mediaAssignment,
mediaAssignment: hybridState.mediaAssignment,
routeRevision: hybridState.routeRevision,
routeAssignment: hybridState.routeAssignment,
qualityProfileId:
this.qualityProfileIdsByRoom.get(participant.roomId) ??
DEFAULT_QUALITY_PROFILE_ID,
@@ -369,24 +400,11 @@ export class SignalingServer {
}
}
if (this.options.peerAssistedMedia) {
this.sendMediaAssignmentChanges(
participant.roomId,
assignmentChanges,
participant.peerId,
if (hybridState) {
this.hybridMediaRouter!.completeAuthentication(
routeParticipant,
hybridState,
);
if (
participant.role === "viewer" &&
mediaAssignment?.parentPeerId &&
!assignmentChanges.some(
({ peerId }) => peerId === mediaAssignment.parentPeerId,
)
) {
this.sendCurrentParentAssignment(
participant.roomId,
participant.peerId,
);
}
}
if (participant.role === "host") {
@@ -477,12 +495,82 @@ export class SignalingServer {
});
}
return;
case "relay-capacity":
if (
!this.options.peerAssistedMedia ||
authenticated.role !== "viewer"
) {
this.sendError(
socket,
"FORBIDDEN",
"Only a peer-assisted viewer may advertise relay capacity",
);
return;
}
this.hybridMediaRouter!.setViewerRelayCapacity(
{
...authenticated,
sessionId: this.socketStates.get(socket)!.sessionId,
},
message.downstreamEdges,
);
return;
case "route-ready":
if (!this.options.peerAssistedMedia) {
this.sendError(socket, "FORBIDDEN", "Media routes are not enabled");
return;
}
this.hybridMediaRouter!.handleRouteReady(
{
...authenticated,
sessionId: this.socketStates.get(socket)!.sessionId,
},
message,
);
return;
case "route-failed":
if (!this.options.peerAssistedMedia) {
this.sendError(socket, "FORBIDDEN", "Media routes are not enabled");
return;
}
this.hybridMediaRouter!.handleRouteFailed(
{
...authenticated,
sessionId: this.socketStates.get(socket)!.sessionId,
},
message,
);
return;
case "refresh-sfu":
if (!this.options.sfuFallback) {
this.sendError(socket, "FORBIDDEN", "SFU fallback is not enabled");
return;
}
this.hybridMediaRouter!.refreshSfu(
{
...authenticated,
sessionId: this.socketStates.get(socket)!.sessionId,
},
message.revision,
);
return;
case "stop-sharing":
case "close-room":
if (authenticated.role !== "host") {
this.sendError(socket, "FORBIDDEN", "Only the host may stop sharing");
return;
}
if (
authenticated.shareGeneration === null ||
this.shareGenerationsByRoom.get(authenticated.roomId) !==
authenticated.shareGeneration ||
(message.type === "stop-sharing" &&
message.shareGeneration !== undefined &&
message.shareGeneration !== authenticated.shareGeneration)
) {
socket.close(4001, "Sharing generation replaced");
return;
}
const state = this.socketStates.get(socket);
if (state?.authenticated === authenticated) {
this.options.roomStore.disconnectParticipant(
@@ -580,12 +668,12 @@ export class SignalingServer {
return;
}
const parentToChild = this.peerRelayTopology.isParentOf(
const parentToChild = this.hybridMediaRouter!.isActivePeerParentOf(
source.roomId,
source.peerId,
targetPeerId,
);
const childToParent = this.peerRelayTopology.isParentOf(
const childToParent = this.hybridMediaRouter!.isActivePeerParentOf(
source.roomId,
targetPeerId,
source.peerId,
@@ -629,7 +717,7 @@ export class SignalingServer {
const targetPeerId = message.targetPeerId;
if (
!targetPeerId ||
!this.peerRelayTopology.isParentOf(
!this.hybridMediaRouter!.isActivePeerParentOf(
source.roomId,
targetPeerId,
source.peerId,
@@ -694,6 +782,7 @@ export class SignalingServer {
if (!disconnected) {
return;
}
this.hybridMediaRouter?.disconnectParticipant(disconnected.roomId);
if (disconnected.role === "host") {
for (const viewer of this.options.roomStore.getConnectedViewers(
disconnected.roomId,
@@ -715,19 +804,10 @@ export class SignalingServer {
this.connectionIdsByViewer.delete(
viewerConnectionKey(disconnected.roomId, disconnected.peerId),
);
if (this.options.peerAssistedMedia) {
const assignmentChanges = this.peerRelayTopology.removeViewer(
if (this.hybridMediaRouter) {
this.hybridMediaRouter.removeViewer(
disconnected.roomId,
disconnected.peerId,
this.connectedPeerIds(disconnected.roomId),
);
this.clearChangedConnectionIds(
disconnected.roomId,
assignmentChanges,
);
this.sendMediaAssignmentChanges(
disconnected.roomId,
assignmentChanges,
);
return;
}
@@ -746,6 +826,7 @@ export class SignalingServer {
private stopSharing(roomId: string): void {
this.clearRoomConnectionIds(roomId);
this.hybridMediaRouter?.stopRoom(roomId);
for (const viewer of this.options.roomStore.getConnectedViewers(roomId)) {
this.sendToSession(viewer.sessionId, { type: "sharing-stopped" });
this.sendToSession(viewer.sessionId, { type: "host-status", online: false });
@@ -759,8 +840,9 @@ export class SignalingServer {
}
this.clearRoomGraceTimers(roomId);
this.clearRoomConnectionIds(roomId);
this.peerRelayTopology.deleteRoom(roomId);
this.hybridMediaRouter?.deleteRoom(roomId);
this.qualityProfileIdsByRoom.delete(roomId);
this.shareGenerationsByRoom.delete(roomId);
for (const sessionId of abandoned.sessionIds) {
const socket = this.socketsBySessionId.get(sessionId);
if (!socket) {
@@ -775,8 +857,9 @@ export class SignalingServer {
for (const expired of this.options.roomStore.expireRooms(this.now())) {
this.clearRoomGraceTimers(expired.roomId);
this.clearRoomConnectionIds(expired.roomId);
this.peerRelayTopology.deleteRoom(expired.roomId);
this.hybridMediaRouter?.deleteRoom(expired.roomId);
this.qualityProfileIdsByRoom.delete(expired.roomId);
this.shareGenerationsByRoom.delete(expired.roomId);
for (const sessionId of expired.sessionIds) {
const socket = this.socketsBySessionId.get(sessionId);
if (!socket) {
@@ -847,19 +930,6 @@ export class SignalingServer {
);
}
private connectedPeerIds(roomId: string): Set<string> {
const peerIds = new Set(
this.options.roomStore
.getConnectedViewers(roomId)
.map((viewer) => viewer.peerId),
);
const host = this.options.roomStore.getConnectedHost(roomId);
if (host) {
peerIds.add(host.peerId);
}
return peerIds;
}
private connectedPeer(roomId: string, peerId: string) {
const host = this.options.roomStore.getConnectedHost(roomId);
if (host?.peerId === peerId) {
@@ -868,66 +938,6 @@ export class SignalingServer {
return this.options.roomStore.getConnectedViewer(roomId, peerId);
}
private clearChangedConnectionIds(
roomId: string,
changes: readonly MediaAssignmentChange[],
): void {
for (const change of changes) {
if (
change.previousParentPeerId !== undefined &&
change.previousParentPeerId !==
change.mediaAssignment.parentPeerId
) {
this.connectionIdsByViewer.delete(
viewerConnectionKey(roomId, change.peerId),
);
}
}
}
private sendMediaAssignmentChanges(
roomId: string,
changes: readonly MediaAssignmentChange[],
excludedPeerId?: string,
): void {
for (const change of changes) {
if (change.peerId === excludedPeerId) {
continue;
}
const peer = this.connectedPeer(roomId, change.peerId);
if (peer) {
this.sendToSession(peer.sessionId, {
type: "media-assignment",
mediaAssignment: change.mediaAssignment,
});
}
}
}
private sendCurrentParentAssignment(
roomId: string,
childPeerId: string,
): void {
const parentPeerId = this.peerRelayTopology.getAssignment(
roomId,
childPeerId,
)?.parentPeerId;
if (!parentPeerId) {
return;
}
const parent = this.connectedPeer(roomId, parentPeerId);
const parentAssignment = this.peerRelayTopology.getAssignment(
roomId,
parentPeerId,
);
if (parent && parentAssignment) {
this.sendToSession(parent.sessionId, {
type: "media-assignment",
mediaAssignment: parentAssignment,
});
}
}
private sendToSession(sessionId: string, message: ServerMessage): void {
const socket = this.socketsBySessionId.get(sessionId);
if (socket) {
+97 -1
View File
@@ -3,6 +3,8 @@ import { z } from "zod";
export const MAX_VIEWERS_PER_ROOM_LIMIT = 16;
export const MAX_SIGNAL_BYTES = 64 * 1024;
export const ROOM_CODE_LENGTH = 12;
export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER;
export const MAX_SFU_TOKEN_LENGTH = 8 * 1024;
const opaqueIdSchema = z
.string()
@@ -33,6 +35,12 @@ export const qualityProfileIdSchema = z.enum([
export type QualityProfileId = z.infer<typeof qualityProfileIdSchema>;
export const DEFAULT_QUALITY_PROFILE_ID: QualityProfileId = "1080p60";
export const relayDownstreamEdgesSchema = z.union([
z.literal(0),
z.literal(1),
]);
export type RelayDownstreamEdges = z.infer<typeof relayDownstreamEdgesSchema>;
const iceServerSchema = z
.object({
urls: z.union([
@@ -95,6 +103,43 @@ export const mediaAssignmentSchema = z
.strict();
export type MediaAssignment = z.infer<typeof mediaAssignmentSchema>;
export const mediaRouteRevisionSchema = z
.number()
.int()
.min(0)
.max(MAX_MEDIA_ROUTE_REVISION);
export const mediaRoutePhaseSchema = z.enum(["prepare", "active"]);
export type MediaRoutePhase = z.infer<typeof mediaRoutePhaseSchema>;
export const mediaRouteUpstreamSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("none") }).strict(),
z
.object({
kind: z.literal("peer"),
peerId: opaqueIdSchema,
})
.strict(),
z.object({ kind: z.literal("sfu") }).strict(),
]);
export type MediaRouteUpstream = z.infer<typeof mediaRouteUpstreamSchema>;
export const sfuPublicationGenerationSchema = opaqueIdSchema;
export const participantRouteAssignmentSchema = z
.object({
upstream: mediaRouteUpstreamSchema,
childPeerIds: z
.array(opaqueIdSchema)
.max(2)
.refine((peerIds) => new Set(peerIds).size === peerIds.length),
sfuPublicationGeneration: sfuPublicationGenerationSchema.nullable(),
})
.strict();
export type ParticipantRouteAssignment = z.infer<
typeof participantRouteAssignmentSchema
>;
const authenticateMessageSchema = z.discriminatedUnion("role", [
z
.object({
@@ -103,6 +148,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [
role: z.literal("host"),
token: tokenSchema,
clientId: opaqueIdSchema,
shareGeneration: opaqueIdSchema.optional(),
})
.strict(),
z
@@ -139,7 +185,39 @@ export const clientMessageSchema = z.union([
qualityProfileId: qualityProfileIdSchema,
})
.strict(),
z.object({ type: z.literal("stop-sharing") }).strict(),
z
.object({
type: z.literal("relay-capacity"),
downstreamEdges: relayDownstreamEdgesSchema,
})
.strict(),
z
.object({
type: z.literal("route-ready"),
revision: mediaRouteRevisionSchema,
phase: mediaRoutePhaseSchema,
})
.strict(),
z
.object({
type: z.literal("route-failed"),
revision: mediaRouteRevisionSchema,
phase: mediaRoutePhaseSchema,
connectionId: opaqueIdSchema.nullable(),
})
.strict(),
z
.object({
type: z.literal("refresh-sfu"),
revision: mediaRouteRevisionSchema,
})
.strict(),
z
.object({
type: z.literal("stop-sharing"),
shareGeneration: opaqueIdSchema.optional(),
})
.strict(),
// Kept as a compatibility alias while previously deployed clients age out.
z.object({ type: z.literal("close-room") }).strict(),
z.object({ type: z.literal("abandon-room") }).strict(),
@@ -177,6 +255,8 @@ const authenticatedMessageSchema = z.union([
...authenticatedMessageShape,
mediaMode: z.literal("peer-assisted"),
mediaAssignment: mediaAssignmentSchema,
routeRevision: mediaRouteRevisionSchema,
routeAssignment: participantRouteAssignmentSchema,
qualityProfileId: qualityProfileIdSchema,
})
.strict(),
@@ -217,6 +297,22 @@ export const serverMessageSchema = z.union([
mediaAssignment: mediaAssignmentSchema,
})
.strict(),
z
.object({
type: z.literal("route-update"),
revision: mediaRouteRevisionSchema,
phase: mediaRoutePhaseSchema,
assignment: participantRouteAssignmentSchema,
})
.strict(),
z
.object({
type: z.literal("sfu-config"),
revision: mediaRouteRevisionSchema,
url: z.string().url().max(2048),
token: z.string().min(1).max(MAX_SFU_TOKEN_LENGTH),
})
.strict(),
z
.object({
type: z.literal("quality-profile"),
+141
View File
@@ -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);
});
});
+335
View File
@@ -0,0 +1,335 @@
import { describe, expect, it } from "vitest";
import {
MediaRouteController,
type PrepareMediaRouteInput,
} from "../src/server/media-route-controller.ts";
import type { ParticipantRouteAssignment } from "../src/shared/protocol.ts";
const HOST = "host_12345678";
const DIRECT_ROOT = "viewer_direct_12345678";
const SFU_ROOT_A = "viewer_sfu_a_12345678";
const SFU_ROOT_B = "viewer_sfu_b_12345678";
const GENERATION_A = "generation_a_12345678";
const GENERATION_B = "generation_b_12345678";
function assignment(
upstream: ParticipantRouteAssignment["upstream"],
childPeerIds: string[] = [],
sfuPublicationGeneration: string | null = null,
): ParticipantRouteAssignment {
return { upstream, childPeerIds, sfuPublicationGeneration };
}
function directAssignments(): Map<string, ParticipantRouteAssignment> {
return new Map([
[HOST, assignment({ kind: "none" }, [DIRECT_ROOT, SFU_ROOT_A])],
[DIRECT_ROOT, assignment({ kind: "peer", peerId: HOST })],
[SFU_ROOT_A, assignment({ kind: "peer", peerId: HOST })],
[SFU_ROOT_B, assignment({ kind: "none" })],
]);
}
function hybridPlan(
generation = GENERATION_A,
rootPeerId = SFU_ROOT_A,
): PrepareMediaRouteInput {
const otherRootPeerId =
rootPeerId === SFU_ROOT_A ? SFU_ROOT_B : SFU_ROOT_A;
return {
assignments: new Map([
[
HOST,
assignment({ kind: "none" }, [DIRECT_ROOT], generation),
],
[DIRECT_ROOT, assignment({ kind: "peer", peerId: HOST })],
[rootPeerId, assignment({ kind: "sfu" })],
[otherRootPeerId, assignment({ kind: "none" })],
]),
expectedParticipantIds: new Set([HOST, rootPeerId]),
sfuPublicationGeneration: generation,
sfuRootPeerIds: [rootPeerId],
};
}
function controller(
assignments = directAssignments(),
): MediaRouteController {
return new MediaRouteController({ hostPeerId: HOST, assignments });
}
function chainedAssignments(): Map<string, ParticipantRouteAssignment> {
return new Map([
[HOST, assignment({ kind: "none" }, [DIRECT_ROOT])],
[
DIRECT_ROOT,
assignment({ kind: "peer", peerId: HOST }, [SFU_ROOT_A]),
],
[SFU_ROOT_A, assignment({ kind: "peer", peerId: DIRECT_ROOT })],
[SFU_ROOT_B, assignment({ kind: "none" })],
]);
}
describe("MediaRouteController", () => {
it("atomically commits only after every expected participant is ready", () => {
const routes = controller();
const revision = routes.prepare(hybridPlan())!;
expect(revision).toBe(1);
expect(routes.getActiveRoute().sfu.publicationGeneration).toBeNull();
expect(routes.ready(HOST, revision, "prepare")).toBe(true);
expect(routes.ready(HOST, revision, "prepare")).toBe(false);
expect(routes.commit(revision)).toBe(false);
expect(routes.getActiveRoute().revision).toBe(0);
expect(routes.ready(SFU_ROOT_A, revision, "prepare")).toBe(true);
expect(routes.getActiveRoute().sfu.publicationGeneration).toBeNull();
expect(routes.commit(revision)).toBe(true);
expect(routes.getPendingRoute()).toBeUndefined();
expect(routes.getActiveRoute()).toMatchObject({
revision,
sfu: {
publicationGeneration: GENERATION_A,
rootPeerIds: [SFU_ROOT_A],
},
});
expect(routes.hostActiveMediaEdges()).toBe(2);
});
it("allows only one pending plan and ignores stale or wrong-phase messages", () => {
const routes = controller();
const revision = routes.prepare(hybridPlan())!;
expect(routes.prepare(hybridPlan(GENERATION_B, SFU_ROOT_B))).toBeUndefined();
expect(routes.ready(HOST, revision - 1, "prepare")).toBe(false);
expect(routes.ready(HOST, revision, "active")).toBe(false);
expect(routes.commit(revision - 1)).toBe(false);
expect(routes.abort(revision - 1)).toBe(false);
expect(routes.getPendingRoute()?.route.revision).toBe(revision);
expect(routes.getActiveRoute().revision).toBe(0);
});
it("aborts to the unchanged active topology with a newer revision", () => {
const routes = controller();
const before = routes.getActiveRoute();
const preparedRevision = routes.prepare(hybridPlan())!;
expect(routes.abort(preparedRevision)).toBe(true);
const after = routes.getActiveRoute();
expect(after.revision).toBe(preparedRevision + 1);
expect(after.assignments).toEqual(before.assignments);
expect(after.sfu).toEqual(before.sfu);
expect(routes.getPendingRoute()).toBeUndefined();
expect(routes.abort(preparedRevision)).toBe(false);
expect(routes.prepare(hybridPlan())).toBe(after.revision + 1);
});
it("reconciles complete baseline snapshots without resetting revisions", () => {
const routes = controller();
const reconciledRevision = routes.reconcileBaseline({
assignments: chainedAssignments(),
});
expect(reconciledRevision).toBe(1);
expect(routes.getActiveRoute().revision).toBe(1);
expect(
routes.getActiveRoute().assignments.get(SFU_ROOT_A)?.upstream,
).toEqual({ kind: "peer", peerId: DIRECT_ROOT });
expect(
routes.reconcileBaseline({ assignments: chainedAssignments() }),
).toBeUndefined();
expect(routes.getActiveRoute().revision).toBe(1);
const preparedRevision = routes.prepare(hybridPlan())!;
expect(
routes.reconcileBaseline({ assignments: directAssignments() }),
).toBeUndefined();
expect(routes.getActiveRoute().revision).toBe(1);
expect(routes.abort(preparedRevision)).toBe(true);
expect(routes.getActiveRoute().revision).toBe(3);
expect(
routes.reconcileBaseline({ assignments: directAssignments() }),
).toBe(4);
expect(routes.getActiveRoute().revision).toBe(4);
expect(routes.hostActiveMediaEdges()).toBe(2);
});
it("preserves active SFU state while reconciling peer topology", () => {
const activePlan = hybridPlan(GENERATION_A, SFU_ROOT_A);
const routes = new MediaRouteController({
hostPeerId: HOST,
assignments: activePlan.assignments,
revision: 6,
sfuPublicationGeneration: GENERATION_A,
sfuRootPeerIds: [SFU_ROOT_A],
});
const assignments = new Map(activePlan.assignments);
assignments.set(
DIRECT_ROOT,
assignment({ kind: "peer", peerId: HOST }, [SFU_ROOT_B]),
);
assignments.set(
SFU_ROOT_B,
assignment({ kind: "peer", peerId: DIRECT_ROOT }),
);
expect(routes.reconcileBaseline({ assignments })).toBe(7);
expect(routes.getActiveRoute().sfu).toEqual({
publicationGeneration: GENERATION_A,
rootPeerIds: [SFU_ROOT_A],
});
expect(routes.hostActiveMediaEdges()).toBe(2);
});
it("never overlaps old and new SFU publication generations", () => {
const initialPlan = hybridPlan(GENERATION_A, SFU_ROOT_A);
const routes = new MediaRouteController({
hostPeerId: HOST,
assignments: initialPlan.assignments,
revision: 4,
sfuPublicationGeneration: GENERATION_A,
sfuRootPeerIds: [SFU_ROOT_A],
});
const preparedRevision = routes.prepare(
hybridPlan(GENERATION_B, SFU_ROOT_B),
)!;
expect(routes.getActiveRoute().sfu).toEqual({
publicationGeneration: GENERATION_A,
rootPeerIds: [SFU_ROOT_A],
});
expect(routes.getPendingRoute()?.route.sfu).toEqual({
publicationGeneration: GENERATION_B,
rootPeerIds: [SFU_ROOT_B],
});
expect(routes.hostActiveMediaEdges()).toBe(2);
routes.ready(HOST, preparedRevision, "prepare");
routes.ready(SFU_ROOT_B, preparedRevision, "prepare");
expect(routes.commit(preparedRevision)).toBe(true);
expect(routes.getActiveRoute().sfu).toEqual({
publicationGeneration: GENERATION_B,
rootPeerIds: [SFU_ROOT_B],
});
expect(routes.hostActiveMediaEdges()).toBe(2);
});
it("rejects host fanout above two and inconsistent SFU ownership", () => {
const tooManyHostEdges = new Map(hybridPlan().assignments);
tooManyHostEdges.set(
HOST,
assignment(
{ kind: "none" },
[DIRECT_ROOT, SFU_ROOT_B],
GENERATION_A,
),
);
tooManyHostEdges.set(
SFU_ROOT_B,
assignment({ kind: "peer", peerId: HOST }),
);
expect(() =>
new MediaRouteController({
hostPeerId: HOST,
assignments: tooManyHostEdges,
sfuPublicationGeneration: GENERATION_A,
sfuRootPeerIds: [SFU_ROOT_A],
}),
).toThrow("Host active media edge budget exceeded");
const nonHostPublisher = directAssignments();
nonHostPublisher.set(
DIRECT_ROOT,
assignment({ kind: "peer", peerId: HOST }, [], GENERATION_A),
);
expect(() => controller(nonHostPublisher)).toThrow(
"Only the host may own an SFU publication generation",
);
const missingRoot = hybridPlan().assignments;
expect(() =>
new MediaRouteController({
hostPeerId: HOST,
assignments: missingRoot,
sfuPublicationGeneration: GENERATION_A,
sfuRootPeerIds: [SFU_ROOT_B],
}),
).toThrow("Every SFU upstream must belong to the root allowlist");
const generationWithoutRoots = directAssignments();
generationWithoutRoots.set(
HOST,
assignment(
{ kind: "none" },
[DIRECT_ROOT, SFU_ROOT_A],
GENERATION_A,
),
);
expect(() =>
new MediaRouteController({
hostPeerId: HOST,
assignments: generationWithoutRoots,
sfuPublicationGeneration: GENERATION_A,
}),
).toThrow("SFU roots and publication generation must be active together");
});
it("rejects reciprocal peer cycles", () => {
const assignments = new Map<string, ParticipantRouteAssignment>([
[HOST, assignment({ kind: "none" })],
[
SFU_ROOT_A,
assignment({ kind: "peer", peerId: SFU_ROOT_B }, [SFU_ROOT_B]),
],
[
SFU_ROOT_B,
assignment({ kind: "peer", peerId: SFU_ROOT_A }, [SFU_ROOT_A]),
],
]);
expect(() => controller(assignments)).toThrow(
"Peer media route contains a cycle",
);
});
it("preserves budgets and monotonic revisions across mixed sequences", () => {
const routes = controller();
let previousRevision = routes.getActiveRoute().revision;
for (let index = 0; index < 60; index += 1) {
const plan =
index % 2 === 0
? hybridPlan(GENERATION_A, SFU_ROOT_A)
: {
assignments: directAssignments(),
expectedParticipantIds: new Set([HOST]),
};
const revision = routes.prepare(plan)!;
expect(revision).toBeGreaterThan(previousRevision);
expect(routes.prepare(plan)).toBeUndefined();
expect(routes.ready(HOST, revision - 1, "prepare")).toBe(false);
expect(routes.commit(revision - 1)).toBe(false);
expect(routes.hostActiveMediaEdges()).toBeLessThanOrEqual(2);
expect(routes.ready(HOST, revision, "prepare")).toBe(true);
expect(routes.ready(HOST, revision, "prepare")).toBe(false);
if (index % 3 === 0) {
expect(routes.abort(revision)).toBe(true);
} else {
if (plan.expectedParticipantIds.has(SFU_ROOT_A)) {
expect(routes.ready(SFU_ROOT_A, revision, "prepare")).toBe(true);
}
expect(routes.commit(revision)).toBe(true);
}
const active = routes.getActiveRoute();
expect(active.revision).toBeGreaterThan(previousRevision);
expect(routes.getPendingRoute()).toBeUndefined();
expect(routes.hostActiveMediaEdges()).toBeLessThanOrEqual(2);
previousRevision = active.revision;
}
});
});
+297 -22
View File
@@ -6,6 +6,24 @@ function connected(...peerIds: string[]): Set<string> {
return new Set(peerIds);
}
function addRelayViewer(
topology: PeerRelayTopology,
roomId: string,
peerId: string,
connectedPeerIds: ReadonlySet<string>,
) {
const changes = topology.addViewer(roomId, peerId, connectedPeerIds);
return [
...changes,
...topology.setViewerRelayCapacity(
roomId,
peerId,
1,
connectedPeerIds,
),
];
}
function expectValidTree(
topology: PeerRelayTopology,
roomId: string,
@@ -42,6 +60,98 @@ function expectValidTree(
}
describe("PeerRelayTopology", () => {
it("keeps viewers as leaves until a relay slot is explicitly advertised", () => {
const topology = new PeerRelayTopology();
const peers = connected("host", "viewer-a", "viewer-b", "viewer-c");
topology.setHost("room", "host", peers);
topology.addViewer("room", "viewer-a", peers);
topology.addViewer("room", "viewer-b", peers);
topology.addViewer("room", "viewer-c", peers);
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBeNull();
expect(
topology.setViewerRelayCapacity("room", "viewer-a", 0, peers),
).toEqual([]);
const changes = topology.setViewerRelayCapacity(
"room",
"viewer-a",
1,
peers,
);
expect(changes.map(({ peerId }) => peerId).sort()).toEqual([
"viewer-a",
"viewer-c",
]);
expect(topology.getAssignment("room", "viewer-a")).toEqual({
parentPeerId: "host",
childPeerIds: ["viewer-c"],
});
expect(topology.getAssignment("room", "viewer-b")?.parentPeerId).toBe(
"host",
);
});
it("does not migrate healthy edges when a relay withdraws capacity", () => {
const topology = new PeerRelayTopology();
const peers = connected("host", "viewer-a", "viewer-b", "viewer-c", "viewer-d");
topology.setHost("room", "host", peers);
topology.addViewer("room", "viewer-a", peers);
topology.addViewer("room", "viewer-b", peers);
topology.setViewerRelayCapacity("room", "viewer-a", 1, peers);
topology.addViewer("room", "viewer-c", peers);
expect(
topology.setViewerRelayCapacity("room", "viewer-a", 0, peers),
).toEqual([]);
topology.addViewer("room", "viewer-d", peers);
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBe(
"viewer-a",
);
expect(topology.getAssignment("room", "viewer-d")?.parentPeerId).toBeNull();
});
it("does not reassign a failed edge through a viewer leaf", () => {
const topology = new PeerRelayTopology();
const peers = connected("host", "viewer-a", "viewer-b", "viewer-c");
topology.setHost("room", "host", peers);
topology.addViewer("room", "viewer-a", peers);
topology.addViewer("room", "viewer-b", peers);
topology.setViewerRelayCapacity("room", "viewer-a", 1, peers);
topology.addViewer("room", "viewer-c", peers);
expect(
topology.reassignViewer(
"room",
"viewer-c",
peers,
new Set(["viewer-a"]),
),
).toBeUndefined();
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBe(
"viewer-a",
);
});
it("requires a reconnecting viewer to advertise capacity again", () => {
const topology = new PeerRelayTopology();
const peers = connected("host", "viewer-a", "viewer-b", "viewer-c", "viewer-d");
topology.setHost("room", "host", peers);
topology.addViewer("room", "viewer-a", peers);
topology.addViewer("room", "viewer-b", peers);
topology.setViewerRelayCapacity("room", "viewer-a", 1, peers);
topology.addViewer("room", "viewer-c", peers);
topology.addViewer("room", "viewer-a", peers);
topology.addViewer("room", "viewer-d", peers);
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBe(
"viewer-a",
);
expect(topology.getAssignment("room", "viewer-d")?.parentPeerId).toBeNull();
});
it("fills the shallowest stable slots with bounded fanout", () => {
const topology = new PeerRelayTopology();
const roomId = "room";
@@ -50,7 +160,7 @@ describe("PeerRelayTopology", () => {
const viewers = ["viewer-a", "viewer-b", "viewer-c", "viewer-d", "viewer-e"];
for (const viewer of viewers) {
topology.addViewer(roomId, viewer, connected(host, ...viewers));
addRelayViewer(topology, roomId, viewer, connected(host, ...viewers));
}
expect(topology.getAssignment(roomId, host)).toEqual({
@@ -81,7 +191,8 @@ describe("PeerRelayTopology", () => {
);
topology.setHost(roomId, hostPeerId, connected(hostPeerId));
for (const peerId of viewerPeerIds) {
topology.addViewer(
addRelayViewer(
topology,
roomId,
peerId,
connected(hostPeerId, ...viewerPeerIds),
@@ -91,23 +202,63 @@ describe("PeerRelayTopology", () => {
expect(expectValidTree(topology, roomId, hostPeerId, viewerPeerIds)).toBe(4);
});
it("uses the same depth-four bound for initial admission", () => {
const topology = new PeerRelayTopology();
const roomId = "room";
const peerIds = [
"host",
"viewer-a",
"viewer-b",
"viewer-c",
"viewer-d",
"viewer-e",
"viewer-f",
];
const peers = connected(...peerIds);
topology.setHost(roomId, "host", peers);
topology.addViewer(roomId, "viewer-a", peers);
topology.setViewerRelayCapacity(roomId, "viewer-a", 1, peers);
topology.addViewer(roomId, "viewer-b", peers);
for (const peerId of ["viewer-c", "viewer-d", "viewer-e"]) {
topology.addViewer(roomId, peerId, peers);
topology.setViewerRelayCapacity(roomId, peerId, 1, peers);
}
topology.addViewer(roomId, "viewer-f", peers);
expect(topology.getAssignment(roomId, "viewer-e")?.parentPeerId).toBe(
"viewer-d",
);
expect(topology.getAssignment(roomId, "viewer-f")?.parentPeerId).toBeNull();
expect(
expectValidTree(
topology,
roomId,
"host",
peerIds.slice(1, -1),
),
).toBe(4);
});
it("leaves a disconnected viewer subtree sticky during its grace window", () => {
const topology = new PeerRelayTopology();
const roomId = "room";
topology.setHost(roomId, "host", connected("host"));
topology.addViewer(roomId, "viewer-a", connected("host", "viewer-a"));
topology.addViewer(
addRelayViewer(topology, roomId, "viewer-a", connected("host", "viewer-a"));
addRelayViewer(
topology,
roomId,
"viewer-b",
connected("host", "viewer-a", "viewer-b"),
);
topology.addViewer(
addRelayViewer(
topology,
roomId,
"viewer-c",
connected("host", "viewer-a", "viewer-b", "viewer-c"),
);
topology.addViewer(
addRelayViewer(
topology,
roomId,
"viewer-d",
connected("host", "viewer-b", "viewer-c", "viewer-d"),
@@ -125,23 +276,27 @@ describe("PeerRelayTopology", () => {
const topology = new PeerRelayTopology();
const roomId = "room";
topology.setHost(roomId, "host", connected("host"));
topology.addViewer(roomId, "viewer-a", connected("host", "viewer-a"));
topology.addViewer(
addRelayViewer(topology, roomId, "viewer-a", connected("host", "viewer-a"));
addRelayViewer(
topology,
roomId,
"viewer-b",
connected("host", "viewer-a", "viewer-b"),
);
topology.addViewer(
addRelayViewer(
topology,
roomId,
"viewer-c",
connected("host", "viewer-a", "viewer-b", "viewer-c"),
);
topology.addViewer(
addRelayViewer(
topology,
roomId,
"viewer-d",
connected("host", "viewer-a", "viewer-b", "viewer-c", "viewer-d"),
);
topology.addViewer(
addRelayViewer(
topology,
roomId,
"viewer-e",
connected(
@@ -180,13 +335,15 @@ describe("PeerRelayTopology", () => {
const topology = new PeerRelayTopology();
const roomId = "room";
topology.setHost(roomId, "host", connected("host"));
topology.addViewer(roomId, "viewer-a", connected("host", "viewer-a"));
topology.addViewer(
addRelayViewer(topology, roomId, "viewer-a", connected("host", "viewer-a"));
addRelayViewer(
topology,
roomId,
"viewer-b",
connected("host", "viewer-a", "viewer-b"),
);
topology.addViewer(
addRelayViewer(
topology,
roomId,
"viewer-c",
connected("host", "viewer-a", "viewer-b", "viewer-c"),
@@ -203,7 +360,8 @@ describe("PeerRelayTopology", () => {
"viewer-b",
]);
const reconnectChanges = topology.addViewer(
const reconnectChanges = addRelayViewer(
topology,
roomId,
"viewer-c",
connected("host", "viewer-b", "viewer-c"),
@@ -223,14 +381,15 @@ describe("PeerRelayTopology", () => {
const roomId = "room";
topology.setHost(roomId, "host", connected("host"));
for (const peerId of ["a", "b", "c", "d"]) {
topology.addViewer(
addRelayViewer(
topology,
roomId,
peerId,
connected("host", "a", "b", "c", "d"),
);
}
topology.addViewer(roomId, "e", connected("host", "c", "d", "e"));
addRelayViewer(topology, roomId, "e", connected("host", "c", "d", "e"));
expect(topology.getAssignment(roomId, "e")?.parentPeerId).toBeNull();
topology.removeViewer(roomId, "a", connected("host", "c", "d", "e"));
@@ -242,7 +401,7 @@ describe("PeerRelayTopology", () => {
it("keeps pending viewers unassigned until a host arrives", () => {
const topology = new PeerRelayTopology();
topology.addViewer("room", "viewer-a", connected("viewer-a"));
addRelayViewer(topology, "room", "viewer-a", connected("viewer-a"));
expect(topology.getAssignment("room", "viewer-a")?.parentPeerId).toBeNull();
topology.setHost(
@@ -258,17 +417,20 @@ describe("PeerRelayTopology", () => {
it("updates only direct root parents when the host identity changes", () => {
const topology = new PeerRelayTopology();
topology.setHost("room", "host-a", connected("host-a"));
topology.addViewer(
addRelayViewer(
topology,
"room",
"viewer-a",
connected("host-a", "viewer-a"),
);
topology.addViewer(
addRelayViewer(
topology,
"room",
"viewer-b",
connected("host-a", "viewer-a", "viewer-b"),
);
topology.addViewer(
addRelayViewer(
topology,
"room",
"viewer-c",
connected("host-a", "viewer-a", "viewer-b", "viewer-c"),
@@ -291,10 +453,123 @@ describe("PeerRelayTopology", () => {
);
});
it("reassigns a failed edge deterministically without entering its subtree", () => {
const topology = new PeerRelayTopology();
const peers = ["host", "a", "b", "c", "d", "e"];
topology.setHost("room", "host", connected(...peers));
for (const peerId of peers.slice(1)) {
addRelayViewer(topology, "room", peerId, connected(...peers));
}
expect(topology.getAssignment("room", "e")?.parentPeerId).toBe("c");
const changes = topology.reassignViewer(
"room",
"e",
connected(...peers),
new Set(["c"]),
4,
);
expect(changes?.map(({ peerId }) => peerId).sort()).toEqual([
"c",
"d",
"e",
]);
expect(topology.getAssignment("room", "e")?.parentPeerId).toBe("d");
expect(expectValidTree(topology, "room", "host", peers.slice(1))).toBe(3);
});
it("does not mutate when every bounded reparent candidate is excluded", () => {
const topology = new PeerRelayTopology();
topology.setHost("room", "host", connected("host", "a", "b", "c"));
for (const peerId of ["a", "b", "c"]) {
addRelayViewer(
topology,
"room",
peerId,
connected("host", "a", "b", "c"),
);
}
const before = topology.getAssignments("room");
expect(
topology.reassignViewer(
"room",
"c",
connected("host", "a", "b", "c"),
new Set(["host", "a", "b"]),
4,
),
).toBeUndefined();
expect(topology.getAssignments("room")).toEqual(before);
});
it("rejects a reparent whose deepest descendant would exceed max depth", () => {
const topology = new PeerRelayTopology();
const peers = ["host", "a", "b", "c", "d", "e", "f", "g", "h"];
topology.setHost("room", "host", connected(...peers));
for (const peerId of peers.slice(1)) {
addRelayViewer(topology, "room", peerId, connected(...peers));
}
const before = topology.getAssignments("room");
expect(topology.getAssignment("room", "g")?.parentPeerId).toBe("e");
expect(
topology.reassignViewer(
"room",
"g",
connected(...peers),
new Set(["e"]),
4,
),
).toBeUndefined();
expect(topology.getAssignments("room")).toEqual(before);
});
it("does not attach a pending subtree beyond the initial-admission depth bound", () => {
const topology = new PeerRelayTopology();
const initialPeers = ["host", "a", "b", "c", "d", "e", "f", "g", "h"];
topology.setHost("room", "host", connected(...initialPeers));
for (const peerId of initialPeers.slice(1)) {
addRelayViewer(topology, "room", peerId, connected(...initialPeers));
}
expect(topology.getAssignment("room", "e")).toMatchObject({
parentPeerId: "c",
childPeerIds: ["g"],
});
topology.removeViewer(
"room",
"c",
connected("host", "a", "b", "d", "f", "h"),
);
topology.removeViewer(
"room",
"h",
connected("host", "a", "b", "d", "f"),
);
topology.addViewer(
"room",
"x",
connected("host", "a", "b", "d", "f", "x"),
);
topology.addViewer(
"room",
"e",
connected("host", "a", "b", "d", "e", "f", "g", "x"),
);
expect(topology.getAssignment("room", "e")?.parentPeerId).toBeNull();
expect(topology.getAssignment("room", "g")?.parentPeerId).toBe("e");
expect(topology.getAssignment("room", "f")?.childPeerIds).toEqual([]);
});
it("drops all assignments when a room is deleted", () => {
const topology = new PeerRelayTopology();
topology.setHost("room", "host-a", connected("host-a"));
topology.addViewer(
addRelayViewer(
topology,
"room",
"viewer-a",
connected("host-a", "viewer-a"),
+187
View File
@@ -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) => {
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
detectBrowserRelayCapacity,
relayCapacityMessageForBrowser,
} from "../src/client/media/relay-capability.ts";
const desktop = {
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140 Safari/537.36",
userAgentDataMobile: false,
maxTouchPoints: 0,
};
describe("browser relay capability", () => {
it("allows one downstream edge on a desktop browser", () => {
expect(detectBrowserRelayCapacity(desktop)).toBe(1);
expect(relayCapacityMessageForBrowser(true, desktop)).toEqual({
type: "relay-capacity",
downstreamEdges: 1,
});
});
it.each([
{
userAgent: desktop.userAgent,
userAgentDataMobile: true,
maxTouchPoints: 0,
},
{
userAgent: "Mozilla/5.0 (Linux; Android 16; Mobile)",
userAgentDataMobile: false,
maxTouchPoints: 5,
},
{
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X)",
maxTouchPoints: 5,
},
{
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)",
userAgentDataMobile: false,
maxTouchPoints: 5,
},
])("keeps mobile and touch iPadOS browsers as leaves", (environment) => {
expect(detectBrowserRelayCapacity(environment)).toBe(0);
});
it("does not create a relay message for ordinary P2P", () => {
expect(relayCapacityMessageForBrowser(false)).toBeNull();
});
});
File diff suppressed because it is too large. Load diff
+159
View File
@@ -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", () => {
+26 -1
View File
@@ -52,7 +52,10 @@ function testConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
async function start(
config = testConfig(),
options: Pick<CreateServerOptions, "now" | "accessSessionTtlSeconds"> = {},
options: Pick<
CreateServerOptions,
"now" | "accessSessionTtlSeconds" | "sfuTokenIssuer"
> = {},
): Promise<string> {
runningServer = await createScreenerServer({
...options,
@@ -335,6 +338,28 @@ describe("room HTTP API", () => {
});
describe("server HTTP listener and health", () => {
it("starts with an injected optional SFU token issuer", async () => {
const baseUrl = await start(
testConfig({
peerAssistedMedia: true,
livekitFallback: {
url: "ws://livekit.test:7880",
apiKey: "test-key",
apiSecret: "s".repeat(32),
maxSfuRootsPerRoom: 2,
},
}),
{
sfuTokenIssuer: {
issueToken: async () => "unused-test-token",
},
},
);
const response = await fetch(`${baseUrl}/healthz`);
expect(response.status).toBe(200);
});
it("uses the configured listen host by default", async () => {
runningServer = await createScreenerServer({
config: testConfig(),
File diff suppressed because it is too large. Load diff
+460
View File
@@ -0,0 +1,460 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SfuPublisher } from "../src/client/sfu/publisher.ts";
import { SfuSubscriber } from "../src/client/sfu/subscriber.ts";
type EventHandler = (...args: unknown[]) => void;
const livekit = vi.hoisted(() => {
class FakeSender {
parameters: RTCRtpSendParameters = {
codecs: [],
encodings: [{}],
headerExtensions: [],
rtcp: { cname: "fake", reducedSize: true },
transactionId: "fake",
};
readonly getParameters = vi.fn(() => this.parameters);
readonly setParameters = vi.fn(
async (parameters: RTCRtpSendParameters): Promise<void> => {
this.parameters = parameters;
},
);
}
class FakeLocalTrack {
currentTrack: MediaStreamTrack;
readonly sender = new FakeSender();
constructor(track: MediaStreamTrack) {
this.currentTrack = track;
}
readonly replaceTrack = vi.fn(
async (nextTrack: MediaStreamTrack): Promise<void> => {
this.currentTrack = nextTrack;
},
);
}
class FakeLocalParticipant {
readonly publications: Array<{
track: FakeLocalTrack;
videoTrack?: FakeLocalTrack;
rawTrack: MediaStreamTrack;
options: Record<string, unknown>;
}> = [];
readonly publishTrack = vi.fn(
async (
rawTrack: MediaStreamTrack,
options: Record<string, unknown>,
) => {
const localTrack = new FakeLocalTrack(rawTrack);
const publication = {
track: localTrack,
videoTrack: rawTrack.kind === "video" ? localTrack : undefined,
rawTrack,
options,
};
this.publications.push(publication);
return publication;
},
);
readonly unpublishTrack = vi.fn(
async (_track: FakeLocalTrack, _stopOnUnpublish: boolean) => undefined,
);
}
class FakeRemotePublication {
readonly setSubscribed = vi.fn((_subscribed: boolean) => undefined);
constructor(
readonly trackSid: string,
readonly source: string,
) {}
}
class FakeRemoteParticipant {
readonly trackPublications = new Map<string, FakeRemotePublication>();
constructor(readonly identity: string) {}
add(publication: FakeRemotePublication): this {
this.trackPublications.set(publication.trackSid, publication);
return this;
}
}
class FakeRoom {
readonly localParticipant = new FakeLocalParticipant();
readonly remoteParticipants = new Map<string, FakeRemoteParticipant>();
readonly handlers = new Map<string, EventHandler[]>();
readonly connect = vi.fn(
async (
_url: string,
_token: string,
_options: Record<string, unknown>,
): Promise<void> => {
await state.connectGate;
},
);
readonly disconnect = vi.fn(async (_stopTracks?: boolean) => undefined);
constructor() {
state.rooms.push(this);
}
on(event: string, handler: EventHandler): this {
const handlers = this.handlers.get(event) ?? [];
handlers.push(handler);
this.handlers.set(event, handlers);
return this;
}
emit(event: string, ...args: unknown[]): void {
for (const handler of this.handlers.get(event) ?? []) {
handler(...args);
}
}
}
const state: {
rooms: FakeRoom[];
connectGate: Promise<void> | null;
} = {
rooms: [],
connectGate: null,
};
return {
FakeRemoteParticipant,
FakeRemotePublication,
FakeRoom,
state,
};
});
const RoomEvent = {
Disconnected: "disconnected",
ParticipantDisconnected: "participant-disconnected",
TrackPublished: "track-published",
TrackSubscribed: "track-subscribed",
TrackUnpublished: "track-unpublished",
TrackUnsubscribed: "track-unsubscribed",
} as const;
const Track = {
Source: {
Camera: "camera",
ScreenShare: "screen-share",
ScreenShareAudio: "screen-share-audio",
},
} as const;
vi.mock("livekit-client", () => ({
Room: livekit.FakeRoom,
RoomEvent,
Track,
}));
class FakeMediaStream {
constructor(private readonly tracks: MediaStreamTrack[] = []) {}
getTracks(): MediaStreamTrack[] {
return [...this.tracks];
}
getVideoTracks(): MediaStreamTrack[] {
return this.tracks.filter((item) => item.kind === "video");
}
getAudioTracks(): MediaStreamTrack[] {
return this.tracks.filter((item) => item.kind === "audio");
}
}
const connection = {
url: "wss://sfu.example.test",
token: "short-lived-token",
};
const qualityProfile = {
label: "1080p 60",
width: 1920,
height: 1080,
frameRate: 60,
maxBitrate: 8_000_000,
} as const;
function track(kind: "video" | "audio", id: string): MediaStreamTrack {
return { id, kind } as MediaStreamTrack;
}
function stream(...tracks: MediaStreamTrack[]): MediaStream {
return new FakeMediaStream(tracks) as unknown as MediaStream;
}
function deferred(): {
promise: Promise<void>;
resolve: () => void;
} {
let resolve = (): void => undefined;
const promise = new Promise<void>((next) => {
resolve = next;
});
return { promise, resolve };
}
beforeEach(() => {
livekit.state.rooms.length = 0;
livekit.state.connectGate = null;
vi.stubGlobal("MediaStream", FakeMediaStream);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("SfuPublisher", () => {
it("prepares a connection without publishing media", async () => {
const publisher = new SfuPublisher();
await expect(publisher.connect(connection)).resolves.toBe(true);
const room = livekit.state.rooms[0];
expect(room.connect).toHaveBeenCalledWith(connection.url, connection.token, {
autoSubscribe: false,
});
expect(room.localParticipant.publishTrack).not.toHaveBeenCalled();
await publisher.disconnect();
await publisher.disconnect();
expect(room.disconnect).toHaveBeenCalledOnce();
expect(room.disconnect).toHaveBeenCalledWith(false);
});
it("publishes one screen video and optional audio only while active", async () => {
const publisher = new SfuPublisher();
const video = track("video", "video-1");
const audio = track("audio", "audio-1");
await publisher.connect(connection);
await expect(
publisher.activate(stream(video, audio), qualityProfile),
).resolves.toBe(true);
const room = livekit.state.rooms[0];
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(1, video, {
source: Track.Source.ScreenShare,
simulcast: false,
screenShareEncoding: {
maxBitrate: 8_000_000,
maxFramerate: 60,
},
degradationPreference: "balanced",
});
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(2, audio, {
source: Track.Source.ScreenShareAudio,
dtx: false,
});
await expect(publisher.deactivate()).resolves.toBe(true);
expect(room.localParticipant.unpublishTrack).toHaveBeenCalledTimes(2);
expect(room.disconnect).not.toHaveBeenCalled();
});
it("updates the active sender profile without republishing", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
const room = livekit.state.rooms[0];
const videoPublication = room.localParticipant.publications[0];
await expect(
publisher.updateProfile({
label: "720p 30",
width: 1280,
height: 720,
frameRate: 30,
maxBitrate: 3_000_000,
}),
).resolves.toBe(true);
expect(videoPublication.track.sender.setParameters).toHaveBeenCalledWith(
expect.objectContaining({
degradationPreference: "balanced",
encodings: [
expect.objectContaining({
maxBitrate: 3_000_000,
maxFramerate: 30,
}),
],
}),
);
expect(room.localParticipant.publishTrack).toHaveBeenCalledOnce();
});
it("restores the previous video after a partially applied replacement fails", async () => {
const previousVideo = track("video", "video-1");
const nextVideo = track("video", "video-2");
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(previousVideo), qualityProfile);
const localTrack = livekit.state.rooms[0].localParticipant.publications[0].track;
localTrack.replaceTrack.mockImplementationOnce(async (replacement) => {
localTrack.currentTrack = replacement;
throw new Error("sender swap failed after applying");
});
await expect(publisher.replaceStream(stream(nextVideo))).resolves.toBe(false);
expect(localTrack.replaceTrack).toHaveBeenNthCalledWith(1, nextVideo);
expect(localTrack.replaceTrack).toHaveBeenNthCalledWith(2, previousVideo);
expect(localTrack.currentTrack).toBe(previousVideo);
});
it("disconnects fail-closed when replacement rollback also fails", async () => {
const disconnected = vi.fn();
const previousVideo = track("video", "video-1");
const publisher = new SfuPublisher({ onDisconnected: disconnected });
await publisher.connect(connection);
await publisher.activate(stream(previousVideo), qualityProfile);
const room = livekit.state.rooms[0];
const localTrack = room.localParticipant.publications[0].track;
localTrack.replaceTrack
.mockRejectedValueOnce(new Error("replace failed"))
.mockRejectedValueOnce(new Error("rollback failed"));
await expect(
publisher.replaceStream(stream(track("video", "video-2"))),
).rejects.toThrow("rollback failed");
expect(room.disconnect).toHaveBeenCalledWith(false);
expect(disconnected).toHaveBeenCalledOnce();
});
it("does not resurrect a stale connection after disconnect", async () => {
const gate = deferred();
livekit.state.connectGate = gate.promise;
const publisher = new SfuPublisher();
const connecting = publisher.connect(connection);
await vi.waitFor(() => expect(livekit.state.rooms).toHaveLength(1));
const room = livekit.state.rooms[0];
await publisher.disconnect();
gate.resolve();
await expect(connecting).resolves.toBe(false);
expect(room.localParticipant.publishTrack).not.toHaveBeenCalled();
expect(room.disconnect).toHaveBeenCalledOnce();
});
});
describe("SfuSubscriber", () => {
it("stays inactive while prepared and selectively subscribes after activation", async () => {
const gate = deferred();
livekit.state.connectGate = gate.promise;
const streams: Array<MediaStream | null> = [];
const subscriber = new SfuSubscriber({
onStream: (nextStream) => streams.push(nextStream),
});
const connecting = subscriber.connect(connection);
await vi.waitFor(() => expect(livekit.state.rooms).toHaveLength(1));
const room = livekit.state.rooms[0];
const host = new livekit.FakeRemoteParticipant("host");
const hostVideo = new livekit.FakeRemotePublication(
"host-video",
Track.Source.ScreenShare,
);
const hostAudio = new livekit.FakeRemotePublication(
"host-audio",
Track.Source.ScreenShareAudio,
);
const hostCamera = new livekit.FakeRemotePublication(
"host-camera",
Track.Source.Camera,
);
host.add(hostVideo).add(hostAudio).add(hostCamera);
room.remoteParticipants.set("host", host);
const viewer = new livekit.FakeRemoteParticipant("viewer:other");
const viewerScreen = new livekit.FakeRemotePublication(
"viewer-screen",
Track.Source.ScreenShare,
);
viewer.add(viewerScreen);
room.remoteParticipants.set(viewer.identity, viewer);
gate.resolve();
await expect(connecting).resolves.toBe(true);
expect(room.connect).toHaveBeenCalledWith(connection.url, connection.token, {
autoSubscribe: false,
});
expect(hostVideo.setSubscribed).not.toHaveBeenCalled();
expect(streams).toEqual([]);
expect(subscriber.activate()).toBe(true);
expect(hostVideo.setSubscribed).toHaveBeenCalledWith(true);
expect(hostAudio.setSubscribed).toHaveBeenCalledWith(true);
expect(hostCamera.setSubscribed).not.toHaveBeenCalled();
expect(viewerScreen.setSubscribed).not.toHaveBeenCalled();
const audio = track("audio", "audio-1");
room.emit(
RoomEvent.TrackSubscribed,
{ mediaStreamTrack: audio },
hostAudio,
host,
);
expect(streams).toEqual([]);
const video = track("video", "video-1");
room.emit(
RoomEvent.TrackSubscribed,
{ mediaStreamTrack: video },
hostVideo,
host,
);
expect(streams.at(-1)?.getTracks()).toEqual([video, audio]);
expect(subscriber.deactivate()).toBe(true);
expect(hostVideo.setSubscribed).toHaveBeenLastCalledWith(false);
expect(hostAudio.setSubscribed).toHaveBeenLastCalledWith(false);
expect(streams.at(-1)).toBeNull();
expect(room.disconnect).not.toHaveBeenCalled();
});
it("notifies the controller after a terminal room disconnect", async () => {
const disconnected = vi.fn();
const subscriber = new SfuSubscriber({
onStream: vi.fn(),
onDisconnected: disconnected,
});
await subscriber.connect(connection);
const room = livekit.state.rooms[0];
room.emit(RoomEvent.Disconnected);
room.emit(RoomEvent.Disconnected);
expect(disconnected).toHaveBeenCalledOnce();
expect(subscriber.deactivate()).toBe(false);
});
it("does not activate a stale connection after disconnect", async () => {
const gate = deferred();
livekit.state.connectGate = gate.promise;
const subscriber = new SfuSubscriber({ onStream: vi.fn() });
const connecting = subscriber.connect(connection);
await vi.waitFor(() => expect(livekit.state.rooms).toHaveLength(1));
const room = livekit.state.rooms[0];
await subscriber.disconnect();
await subscriber.disconnect();
gate.resolve();
await expect(connecting).resolves.toBe(false);
expect(room.disconnect).toHaveBeenCalledOnce();
expect(() => subscriber.activate()).toThrow("expected prepared");
});
});
+32
View File
@@ -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);
});
});
+124 -2
View File
@@ -104,6 +104,8 @@ class FakePeerConnection extends EventTarget {
const intervalCallbacks = new Map<number, () => void>();
let nextIntervalId = 1;
const timeoutCallbacks = new Map<number, () => void>();
let nextTimeoutId = 1;
function offer(connectionId: string): SignalPayload {
return {
@@ -154,6 +156,8 @@ beforeEach(() => {
FakePeerConnection.plans.length = 0;
intervalCallbacks.clear();
nextIntervalId = 1;
timeoutCallbacks.clear();
nextTimeoutId = 1;
vi.stubGlobal("MediaStream", FakeMediaStream);
vi.stubGlobal("RTCPeerConnection", FakePeerConnection);
vi.stubGlobal("window", {
@@ -163,8 +167,12 @@ beforeEach(() => {
return id;
},
clearInterval: (id: number) => intervalCallbacks.delete(id),
setTimeout,
clearTimeout,
setTimeout: (callback: () => void) => {
const id = nextTimeoutId++;
timeoutCallbacks.set(id, callback);
return id;
},
clearTimeout: (id: number) => timeoutCallbacks.delete(id),
});
});
@@ -173,6 +181,90 @@ afterEach(() => {
});
describe("ViewerPeer connection generations", () => {
it("reports one exhausted edge after bounded restart and rebuild attempts", async () => {
const restartRequests: Array<{
peerId: string;
connectionId: string;
rebuild: boolean;
}> = [];
const exhausted: Array<{ peerId: string; connectionId: string }> = [];
const peer = new ViewerPeer(
{ iceServers: [], expiresAt: null, relayAvailable: false },
{
sendSignal: () => true,
sendRestartRequest: (peerId, connectionId, rebuild) => {
restartRequests.push({ peerId, connectionId, rebuild });
return true;
},
onStream: () => undefined,
onUpdate: () => undefined,
onRecoveryExhausted: (peerId, connectionId) => {
exhausted.push({ peerId, connectionId });
return true;
},
},
);
await peer.acceptSignal("relay-parent", offer("failed-connection"));
const connection = FakePeerConnection.instances[0]!;
connection.connectionState = "failed";
connection.dispatchEvent(new Event("connectionstatechange"));
expect(restartRequests).toEqual([
{
peerId: "relay-parent",
connectionId: "failed-connection",
rebuild: false,
},
]);
timeoutCallbacks.get(1)!();
expect(restartRequests.at(-1)).toEqual({
peerId: "relay-parent",
connectionId: "failed-connection",
rebuild: true,
});
timeoutCallbacks.get(2)!();
expect(exhausted).toEqual([
{ peerId: "relay-parent", connectionId: "failed-connection" },
]);
connection.dispatchEvent(new Event("connectionstatechange"));
expect(restartRequests).toHaveLength(2);
expect(exhausted).toHaveLength(1);
});
it("retries delivery of an exhausted edge after signaling reconnects", async () => {
let reportAvailable = false;
const exhausted: string[] = [];
const peer = new ViewerPeer(
{ iceServers: [], expiresAt: null, relayAvailable: false },
{
sendSignal: () => true,
sendRestartRequest: () => true,
onStream: () => undefined,
onUpdate: () => undefined,
onRecoveryExhausted: (_peerId, connectionId) => {
exhausted.push(connectionId);
return reportAvailable;
},
},
);
await peer.acceptSignal("relay-parent", offer("failed-connection"));
const connection = FakePeerConnection.instances[0]!;
connection.connectionState = "failed";
connection.dispatchEvent(new Event("connectionstatechange"));
timeoutCallbacks.get(1)!();
timeoutCallbacks.get(2)!();
expect(exhausted).toEqual(["failed-connection"]);
reportAvailable = true;
timeoutCallbacks.get(3)!();
expect(exhausted).toEqual(["failed-connection", "failed-connection"]);
connection.dispatchEvent(new Event("connectionstatechange"));
expect(exhausted).toHaveLength(2);
});
it("requests one bounded rebuild when answer negotiation fails", async () => {
FakePeerConnection.plans.push(
{ answerError: new Error("first answer failed") },
@@ -208,6 +300,36 @@ describe("ViewerPeer connection generations", () => {
]);
});
it("requests a rebuild when the completed answer cannot be signaled", async () => {
const restartRequests: Array<{
peerId: string;
connectionId: string;
rebuild: boolean;
}> = [];
const peer = new ViewerPeer(
{ iceServers: [], expiresAt: null, relayAvailable: false },
{
sendSignal: () => false,
sendRestartRequest: (peerId, connectionId, rebuild) => {
restartRequests.push({ peerId, connectionId, rebuild });
return true;
},
onStream: () => undefined,
onUpdate: () => undefined,
},
);
await peer.acceptSignal("relay-parent", offer("unsent-answer"));
expect(restartRequests).toEqual([
{
peerId: "relay-parent",
connectionId: "unsent-answer",
rebuild: true,
},
]);
});
it("targets answers and recovery requests at the current parent", async () => {
const signals: SignalPayload[] = [];
const signalPeers: string[] = [];