fix: use time-sync for authd calls
This commit is contained in:
parent
337e507337
commit
cbb745eebe
@ -8,10 +8,10 @@
|
|||||||
"@noble/hashes": "^1.4.0",
|
"@noble/hashes": "^1.4.0",
|
||||||
"@scure/base": "^1.1.6",
|
"@scure/base": "^1.1.6",
|
||||||
"@snort/shared": "^1.0.17",
|
"@snort/shared": "^1.0.17",
|
||||||
"@snort/system": "^1.5.0",
|
"@snort/system": "^1.5.1",
|
||||||
"@snort/system-react": "^1.5.0",
|
"@snort/system-react": "^1.5.1",
|
||||||
"@snort/system-wasm": "^1.0.5",
|
"@snort/system-wasm": "^1.0.5",
|
||||||
"@snort/wallet": "^0.1.8",
|
"@snort/wallet": "^0.2.0",
|
||||||
"@snort/worker-relay": "^1.3.0",
|
"@snort/worker-relay": "^1.3.0",
|
||||||
"@szhsin/react-menu": "^4.1.0",
|
"@szhsin/react-menu": "^4.1.0",
|
||||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.5",
|
"@types/webscopeio__react-textarea-autocomplete": "^4.7.5",
|
||||||
|
@ -4,6 +4,7 @@ import { useRequestBuilder } from "@snort/system-react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { LIVE_STREAM } from "@/const";
|
import { LIVE_STREAM } from "@/const";
|
||||||
|
import { getHost } from "@/utils";
|
||||||
|
|
||||||
export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPreload?: TaggedNostrEvent) {
|
export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPreload?: TaggedNostrEvent) {
|
||||||
const author = link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
|
const author = link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
|
||||||
@ -13,8 +14,8 @@ export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPrelo
|
|||||||
leaveOpen,
|
leaveOpen,
|
||||||
});
|
});
|
||||||
if (link.type === NostrPrefix.PublicKey || link.type === NostrPrefix.Profile) {
|
if (link.type === NostrPrefix.PublicKey || link.type === NostrPrefix.Profile) {
|
||||||
b.withFilter().authors([link.id]).kinds([LIVE_STREAM]).limit(1);
|
b.withFilter().authors([link.id]).kinds([LIVE_STREAM]);
|
||||||
b.withFilter().tag("p", [link.id]).kinds([LIVE_STREAM]).limit(1);
|
b.withFilter().tag("p", [link.id]).kinds([LIVE_STREAM]);
|
||||||
} else if (link.type === NostrPrefix.Address) {
|
} else if (link.type === NostrPrefix.Address) {
|
||||||
const f = b.withFilter().tag("d", [link.id]);
|
const f = b.withFilter().tag("d", [link.id]);
|
||||||
if (link.author) {
|
if (link.author) {
|
||||||
@ -31,8 +32,8 @@ export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPrelo
|
|||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const hosting = [...q, ...(evPreload ? [evPreload] : [])].filter(
|
const hosting = [...q, ...(evPreload ? [evPreload] : [])].filter(
|
||||||
a => a.pubkey === author || a.tags.some(b => b[0] === "p" && b[1] === author && b[3] === "host"),
|
a => getHost(a) === author || a.pubkey === author
|
||||||
);
|
).sort((a, b) => (b.created_at > a.created_at ? 1 : -1));
|
||||||
return [...(hosting ?? [])].sort((a, b) => (b.created_at > a.created_at ? 1 : -1)).at(0);
|
return hosting.at(0);
|
||||||
}, [q]);
|
}, [q]);
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,8 @@ import { Login } from "@/login";
|
|||||||
import { getPublisher } from "@/login";
|
import { getPublisher } from "@/login";
|
||||||
import { extractStreamInfo } from "@/utils";
|
import { extractStreamInfo } from "@/utils";
|
||||||
import { StreamState } from "@/const";
|
import { StreamState } from "@/const";
|
||||||
import { appendDedupe } from "@snort/shared";
|
import { appendDedupe, unixNow } from "@snort/shared";
|
||||||
|
import { TimeSync } from "@/time-sync";
|
||||||
|
|
||||||
export class NostrStreamProvider implements StreamProvider {
|
export class NostrStreamProvider implements StreamProvider {
|
||||||
#publisher?: EventPublisher;
|
#publisher?: EventPublisher;
|
||||||
@ -170,7 +171,10 @@ export class NostrStreamProvider implements StreamProvider {
|
|||||||
|
|
||||||
const u = `${this.url}${path}`;
|
const u = `${this.url}${path}`;
|
||||||
const token = await pub.generic(eb => {
|
const token = await pub.generic(eb => {
|
||||||
return eb.kind(EventKind.HttpAuthentication).content("").tag(["u", u]).tag(["method", method]);
|
return eb.kind(EventKind.HttpAuthentication)
|
||||||
|
.content("")
|
||||||
|
.tag(["u", u]).tag(["method", method])
|
||||||
|
.createdAt(unixNow() + Math.floor(TimeSync / 1000));
|
||||||
});
|
});
|
||||||
const rsp = await fetch(u, {
|
const rsp = await fetch(u, {
|
||||||
method,
|
method,
|
||||||
|
@ -9,8 +9,8 @@ export async function syncClock() {
|
|||||||
});
|
});
|
||||||
const nowAtServer = (await req.json()).time as number;
|
const nowAtServer = (await req.json()).time as number;
|
||||||
const now = unixNowMs();
|
const now = unixNowMs();
|
||||||
TimeSync = now - nowAtServer;
|
TimeSync = nowAtServer - now;
|
||||||
console.debug("Time clock sync", TimeSync);
|
console.debug(`Time clock sync ${TimeSync}ms`);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
interface StateEventMap {
|
|
||||||
log: CustomEvent<LogEvent>;
|
|
||||||
status: CustomEvent<StatusEvent>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StateEventTarget extends EventTarget {
|
|
||||||
addEventListener<K extends keyof StateEventMap>(
|
|
||||||
type: K,
|
|
||||||
listener: (ev: StateEventMap[K]) => void,
|
|
||||||
options?: boolean | AddEventListenerOptions,
|
|
||||||
): void;
|
|
||||||
addEventListener(
|
|
||||||
type: string,
|
|
||||||
callback: EventListenerOrEventListenerObject | null,
|
|
||||||
options?: EventListenerOptions | boolean,
|
|
||||||
): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TypedEventTarget = EventTarget as {
|
|
||||||
new (): StateEventTarget;
|
|
||||||
prototype: StateEventTarget;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface LogEvent {
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatusEvent {
|
|
||||||
status: Status;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Status = "connected" | "disconnected";
|
|
@ -1,613 +0,0 @@
|
|||||||
import adapter from "webrtc-adapter";
|
|
||||||
import { CandidateInfo, SDPInfo } from "semantic-sdp";
|
|
||||||
import { type LogEvent, type StatusEvent, TypedEventTarget } from "./events";
|
|
||||||
import { parserLinkHeader } from "./parser";
|
|
||||||
|
|
||||||
export const DEFAULT_ICE_SERVERS = ["stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"];
|
|
||||||
|
|
||||||
export const TRICKLE_BATCH_INTERVAL = 50;
|
|
||||||
|
|
||||||
enum Mode {
|
|
||||||
Player = "player",
|
|
||||||
Publisher = "publisher",
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WISH extends TypedEventTarget {
|
|
||||||
private peerConnection?: RTCPeerConnection;
|
|
||||||
private iceServers: string[] = DEFAULT_ICE_SERVERS;
|
|
||||||
|
|
||||||
private videoSender?: RTCRtpSender;
|
|
||||||
|
|
||||||
private remoteTracks: MediaStreamTrack[] = [];
|
|
||||||
private playerMedia?: MediaStream;
|
|
||||||
|
|
||||||
private connecting: boolean = false;
|
|
||||||
private connectedPromise!: Promise<void>;
|
|
||||||
private connectedResolver!: (any: void) => void;
|
|
||||||
private connectedRejector!: (reason?: unknown) => void;
|
|
||||||
private gatherPromise!: Promise<void>;
|
|
||||||
private gatherResolver!: (any: void) => void;
|
|
||||||
|
|
||||||
private endpoint?: string;
|
|
||||||
private resourceURL?: string;
|
|
||||||
private mode: Mode = Mode.Player;
|
|
||||||
private parsedOffer?: SDPInfo;
|
|
||||||
private useTrickle: boolean = false;
|
|
||||||
private etag?: string;
|
|
||||||
|
|
||||||
private trickleBatchingJob?: ReturnType<typeof setInterval>;
|
|
||||||
private batchedCandidates: RTCIceCandidate[] = [];
|
|
||||||
|
|
||||||
private connectStartTime?: number;
|
|
||||||
private iceStartTime?: number;
|
|
||||||
|
|
||||||
constructor(iceServers?: string[]) {
|
|
||||||
super();
|
|
||||||
if (iceServers) {
|
|
||||||
this.iceServers = iceServers ? iceServers : DEFAULT_ICE_SERVERS;
|
|
||||||
}
|
|
||||||
this.logMessage(`Enabling webrtc-adapter for ${adapter.browserDetails.browser}@${adapter.browserDetails.version}`);
|
|
||||||
this.newResolvers();
|
|
||||||
}
|
|
||||||
|
|
||||||
private logMessage(str: string) {
|
|
||||||
const now = new Date().toLocaleString();
|
|
||||||
console.log(`${now}: ${str}`);
|
|
||||||
this.dispatchEvent(
|
|
||||||
new CustomEvent<LogEvent>("log", {
|
|
||||||
detail: {
|
|
||||||
message: str,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private killConnection() {
|
|
||||||
if (this.peerConnection) {
|
|
||||||
this.logMessage("Closing RTCPeerConnection");
|
|
||||||
this.peerConnection.close();
|
|
||||||
this.peerConnection = undefined;
|
|
||||||
this.parsedOffer = undefined;
|
|
||||||
this.playerMedia = undefined;
|
|
||||||
this.videoSender = undefined;
|
|
||||||
this.connecting = false;
|
|
||||||
this.remoteTracks = [];
|
|
||||||
this.batchedCandidates = [];
|
|
||||||
this.stopTrickleBatching();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createConnection() {
|
|
||||||
this.logMessage("Creating a new RTCPeerConnection");
|
|
||||||
this.peerConnection = new RTCPeerConnection({
|
|
||||||
iceServers: [{ urls: this.iceServers }],
|
|
||||||
});
|
|
||||||
if (!this.peerConnection) {
|
|
||||||
throw new Error("Failed to create a new RTCPeerConnection");
|
|
||||||
}
|
|
||||||
this.addEventListeners();
|
|
||||||
this.newResolvers();
|
|
||||||
}
|
|
||||||
|
|
||||||
private newResolvers() {
|
|
||||||
this.connectedPromise = new Promise((resolve, reject) => {
|
|
||||||
this.connectedResolver = resolve;
|
|
||||||
this.connectedRejector = reject;
|
|
||||||
});
|
|
||||||
this.gatherPromise = new Promise(resolve => {
|
|
||||||
this.gatherResolver = resolve;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private addEventListeners() {
|
|
||||||
if (!this.peerConnection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.peerConnection.addEventListener("connectionstatechange", this.onConnectionStateChange.bind(this));
|
|
||||||
this.peerConnection.addEventListener("iceconnectionstatechange", this.onICEConnectionStateChange.bind(this));
|
|
||||||
this.peerConnection.addEventListener("icegatheringstatechange", this.onGatheringStateChange.bind(this));
|
|
||||||
this.peerConnection.addEventListener("icecandidate", this.onICECandidate.bind(this));
|
|
||||||
this.peerConnection.addEventListener("track", this.onTrack.bind(this));
|
|
||||||
this.peerConnection.addEventListener("signalingstatechange", this.onSignalingStateChange.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
private onGatheringStateChange() {
|
|
||||||
if (!this.peerConnection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.logMessage(`ICE Gathering State changed: ${this.peerConnection.iceGatheringState}`);
|
|
||||||
switch (this.peerConnection.iceGatheringState) {
|
|
||||||
case "complete":
|
|
||||||
this.gatherResolver();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private onConnectionStateChange() {
|
|
||||||
if (!this.peerConnection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.logMessage(`Peer Connection State changed: ${this.peerConnection.connectionState}`);
|
|
||||||
const transportHandler = (track: MediaStreamTrack, transport: RTCDtlsTransport) => {
|
|
||||||
const ice = transport.iceTransport;
|
|
||||||
if (!ice) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pair = ice.getSelectedCandidatePair();
|
|
||||||
if (!pair) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pair.local && pair.remote) {
|
|
||||||
this.logMessage(
|
|
||||||
`[${track.kind}] Selected Candidate: (local ${pair.local.address})-(remote ${pair.remote.candidate})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
switch (this.peerConnection.connectionState) {
|
|
||||||
case "connected":
|
|
||||||
switch (this.mode) {
|
|
||||||
case Mode.Player:
|
|
||||||
for (const receiver of this.peerConnection.getReceivers()) {
|
|
||||||
const transport = receiver.transport;
|
|
||||||
if (!transport) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
transportHandler(receiver.track, transport);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Mode.Publisher:
|
|
||||||
for (const sender of this.peerConnection.getSenders()) {
|
|
||||||
const transport = sender.transport;
|
|
||||||
if (!transport) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!sender.track) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (sender.track.kind === "video") {
|
|
||||||
this.videoSender = sender;
|
|
||||||
}
|
|
||||||
transportHandler(sender.track, transport);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "failed":
|
|
||||||
this.dispatchEvent(
|
|
||||||
new CustomEvent<StatusEvent>("status", {
|
|
||||||
detail: {
|
|
||||||
status: "disconnected",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private onICECandidate(ev: RTCPeerConnectionIceEvent) {
|
|
||||||
if (ev.candidate) {
|
|
||||||
const candidate = ev.candidate;
|
|
||||||
if (!candidate.candidate) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.logMessage(`Got ICE candidate: ${candidate.candidate.replace("candidate:", "")}`);
|
|
||||||
if (!this.parsedOffer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.useTrickle) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (candidate.candidate.includes(".local")) {
|
|
||||||
this.logMessage("Skipping mDNS candidate for trickle ICE");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.batchedCandidates.push(candidate);
|
|
||||||
} else {
|
|
||||||
this.logMessage(`End of ICE candidates`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private startTrickleBatching() {
|
|
||||||
if (this.trickleBatchingJob) {
|
|
||||||
clearInterval(this.trickleBatchingJob);
|
|
||||||
}
|
|
||||||
this.logMessage(`Starting batching job to trickle candidates every ${TRICKLE_BATCH_INTERVAL}ms`);
|
|
||||||
this.trickleBatchingJob = setInterval(this.trickleBatch.bind(this), TRICKLE_BATCH_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopTrickleBatching() {
|
|
||||||
if (!this.trickleBatchingJob) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.logMessage("Stopping trickle batching job");
|
|
||||||
clearInterval(this.trickleBatchingJob);
|
|
||||||
this.trickleBatchingJob = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async trickleBatch() {
|
|
||||||
if (!this.parsedOffer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.batchedCandidates.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fragSDP = new SDPInfo();
|
|
||||||
const candidates = this.batchedCandidates.splice(0);
|
|
||||||
this.logMessage(`Tricking with ${candidates.length} candidates`);
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const candidateObject = CandidateInfo.expand({
|
|
||||||
foundation: candidate.foundation || "",
|
|
||||||
componentId: candidate.component === "rtp" ? 1 : 2,
|
|
||||||
transport: candidate.protocol || "udp",
|
|
||||||
priority: candidate.priority || 0,
|
|
||||||
address: candidate.address || "",
|
|
||||||
port: candidate.port || 0,
|
|
||||||
type: candidate.type || "host",
|
|
||||||
relAddr: candidate.relatedAddress || undefined,
|
|
||||||
relPort:
|
|
||||||
typeof candidate.relatedPort !== "undefined" && candidate.relatedPort !== null
|
|
||||||
? candidate.relatedPort.toString()
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
fragSDP.addCandidate(candidateObject);
|
|
||||||
}
|
|
||||||
fragSDP.setICE(this.parsedOffer.getICE());
|
|
||||||
|
|
||||||
const generated = fragSDP.toIceFragmentString();
|
|
||||||
// for trickle-ice-sdpfrag, we need a psuedo m= line
|
|
||||||
const lines = generated.split(/\r?\n/);
|
|
||||||
lines.splice(2, 0, "m=audio 9 RTP/AVP 0");
|
|
||||||
lines.splice(3, 0, "a=mid:0");
|
|
||||||
const frag = lines.join("\r\n");
|
|
||||||
try {
|
|
||||||
await this.doSignalingPATCH(frag, false);
|
|
||||||
} catch (e) {
|
|
||||||
this.logMessage(`Failed to trickle: ${(e as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private onSignalingStateChange() {
|
|
||||||
if (!this.peerConnection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.logMessage(`Signaling State changed: ${this.peerConnection.signalingState}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onICEConnectionStateChange() {
|
|
||||||
if (!this.peerConnection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.logMessage(`ICE Connection State changed: ${this.peerConnection.iceConnectionState}`);
|
|
||||||
switch (this.peerConnection.iceConnectionState) {
|
|
||||||
case "checking":
|
|
||||||
this.iceStartTime = performance.now();
|
|
||||||
break;
|
|
||||||
case "connected": {
|
|
||||||
const connected = performance.now();
|
|
||||||
if (this.connectStartTime) {
|
|
||||||
const delta = connected - this.connectStartTime;
|
|
||||||
this.logMessage(`Took ${(delta / 1000).toFixed(2)} seconds to establish PeerConnection (end-to-end)`);
|
|
||||||
}
|
|
||||||
if (this.iceStartTime) {
|
|
||||||
const delta = connected - this.iceStartTime;
|
|
||||||
this.logMessage(`Took ${(delta / 1000).toFixed(2)} seconds to establish PeerConnection (ICE)`);
|
|
||||||
}
|
|
||||||
this.dispatchEvent(
|
|
||||||
new CustomEvent<StatusEvent>("status", {
|
|
||||||
detail: {
|
|
||||||
status: "connected",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
this.connecting = false;
|
|
||||||
this.connectedResolver();
|
|
||||||
this.stopTrickleBatching();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "failed":
|
|
||||||
if (this.connecting) {
|
|
||||||
this.connectedRejector("ICE failed while trying to connect");
|
|
||||||
this.stopTrickleBatching();
|
|
||||||
this.connecting = false;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private onTrack(ev: RTCTrackEvent) {
|
|
||||||
if (this.mode !== Mode.Player) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.remoteTracks.push(ev.track);
|
|
||||||
|
|
||||||
if (this.remoteTracks.length === 2) {
|
|
||||||
for (const track of this.remoteTracks) {
|
|
||||||
this.logMessage(`Got remote ${track.kind} track`);
|
|
||||||
if (this.playerMedia) {
|
|
||||||
this.playerMedia.addTrack(track);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async waitForICEGather() {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.gatherResolver();
|
|
||||||
}, 1000);
|
|
||||||
await this.gatherPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async doSignaling() {
|
|
||||||
if (!this.peerConnection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.connectStartTime = performance.now();
|
|
||||||
const localOffer = await this.peerConnection.createOffer();
|
|
||||||
if (!localOffer.sdp) {
|
|
||||||
throw new Error("Fail to create offer");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.parsedOffer = SDPInfo.parse(localOffer.sdp);
|
|
||||||
let remoteOffer: string = "";
|
|
||||||
|
|
||||||
if (!this.useTrickle) {
|
|
||||||
await this.peerConnection.setLocalDescription(localOffer);
|
|
||||||
await this.waitForICEGather();
|
|
||||||
const offer = this.peerConnection.localDescription;
|
|
||||||
if (!offer) {
|
|
||||||
throw new Error("no LocalDescription");
|
|
||||||
}
|
|
||||||
remoteOffer = await this.doSignalingPOST(offer.sdp);
|
|
||||||
} else {
|
|
||||||
// ensure that resourceURL is set before trickle happens
|
|
||||||
remoteOffer = await this.doSignalingPOST(localOffer.sdp, true);
|
|
||||||
this.startTrickleBatching();
|
|
||||||
await this.peerConnection.setLocalDescription(localOffer);
|
|
||||||
}
|
|
||||||
await this.peerConnection.setRemoteDescription({
|
|
||||||
sdp: remoteOffer,
|
|
||||||
type: "answer",
|
|
||||||
});
|
|
||||||
this.connecting = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private setVideoCodecPreference(transceiver: RTCRtpTransceiver) {
|
|
||||||
if (typeof RTCRtpSender.getCapabilities === "undefined" || typeof transceiver.setCodecPreferences === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const capability = RTCRtpSender.getCapabilities("video");
|
|
||||||
const codecs = capability ? capability.codecs : [];
|
|
||||||
this.logMessage(`Available codecs for outbound video: ${codecs.map(c => c.mimeType).join(", ")}`);
|
|
||||||
for (let i = 0; i < codecs.length; i++) {
|
|
||||||
const codec = codecs[i];
|
|
||||||
if (codec.mimeType === "video/VP9") {
|
|
||||||
codecs.unshift(codecs.splice(i, 1)[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transceiver.setCodecPreferences(codecs);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async whipOffer(src: MediaStream) {
|
|
||||||
if (!this.peerConnection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const track of src.getTracks()) {
|
|
||||||
this.logMessage(`Adding local ${track.kind} track`);
|
|
||||||
const transceiver = this.peerConnection.addTransceiver(track, {
|
|
||||||
direction: "sendonly",
|
|
||||||
});
|
|
||||||
if (track.kind === "video") {
|
|
||||||
this.setVideoCodecPreference(transceiver);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await this.doSignaling();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async whepClientOffer() {
|
|
||||||
if (!this.peerConnection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.peerConnection.addTransceiver("video", {
|
|
||||||
direction: "recvonly",
|
|
||||||
});
|
|
||||||
this.peerConnection.addTransceiver("audio", {
|
|
||||||
direction: "recvonly",
|
|
||||||
});
|
|
||||||
await this.doSignaling();
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateETag(resp: Response) {
|
|
||||||
const etag = resp.headers.get("etag");
|
|
||||||
if (etag) {
|
|
||||||
try {
|
|
||||||
this.etag = JSON.parse(etag);
|
|
||||||
} catch (e) {
|
|
||||||
this.logMessage("Failed to parse ETag header for PATCH");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.etag) {
|
|
||||||
this.logMessage(`Got ${this.etag} as ETag`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async doSignalingPOST(sdp: string, useLink?: boolean): Promise<string> {
|
|
||||||
if (!this.endpoint) {
|
|
||||||
throw new Error("No WHIP/WHEP endpoint has been set");
|
|
||||||
}
|
|
||||||
const signalStartTime = performance.now();
|
|
||||||
const resp = await fetch(this.endpoint, {
|
|
||||||
method: "POST",
|
|
||||||
mode: "cors",
|
|
||||||
body: sdp,
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/sdp",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const body = await resp.text();
|
|
||||||
if (resp.status !== 201) {
|
|
||||||
throw new Error(`Unexpected status code ${resp.status}: ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resource = resp.headers.get("location");
|
|
||||||
if (resource) {
|
|
||||||
if (resource.startsWith("http")) {
|
|
||||||
// absolute path
|
|
||||||
this.resourceURL = resource;
|
|
||||||
} else {
|
|
||||||
// relative path
|
|
||||||
const parsed = new URL(this.endpoint);
|
|
||||||
parsed.pathname = resource;
|
|
||||||
this.resourceURL = parsed.toString();
|
|
||||||
}
|
|
||||||
this.logMessage(`Using ${this.resourceURL} as WHIP/WHEP Resource URL`);
|
|
||||||
} else {
|
|
||||||
this.logMessage("No Location header in response");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateETag(resp);
|
|
||||||
|
|
||||||
if (resp.headers.get("accept-post") || resp.headers.get("accept-patch")) {
|
|
||||||
switch (this.mode) {
|
|
||||||
case Mode.Publisher:
|
|
||||||
this.logMessage(`WHIP version draft-ietf-wish-whip-05 (Accept-Post/Accept-Patch)`);
|
|
||||||
break;
|
|
||||||
case Mode.Player:
|
|
||||||
this.logMessage(`WHEP version draft-murillo-whep-01 (Accept-Post/Accept-Patch)`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.peerConnection && useLink) {
|
|
||||||
const link = resp.headers.get("link");
|
|
||||||
if (link) {
|
|
||||||
const links = parserLinkHeader(link);
|
|
||||||
if (links["ice-server"]) {
|
|
||||||
const url = links["ice-server"].url;
|
|
||||||
this.logMessage(`Endpoint provided ice-server ${url}`);
|
|
||||||
this.peerConnection.setConfiguration({
|
|
||||||
iceServers: [
|
|
||||||
{
|
|
||||||
urls: [url],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const signaled = performance.now();
|
|
||||||
const delta = signaled - signalStartTime;
|
|
||||||
this.logMessage(`Took ${(delta / 1000).toFixed(2)} seconds to exchange SDP`);
|
|
||||||
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async doSignalingPATCH(frag: string, iceRestart: boolean) {
|
|
||||||
if (!this.resourceURL) {
|
|
||||||
throw new Error("No resource URL");
|
|
||||||
}
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
"content-type": "application/trickle-ice-sdpfrag",
|
|
||||||
};
|
|
||||||
if (this.etag) {
|
|
||||||
headers["if-match"] = this.etag;
|
|
||||||
}
|
|
||||||
const resp = await fetch(this.resourceURL, {
|
|
||||||
method: "PATCH",
|
|
||||||
mode: "cors",
|
|
||||||
body: frag,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
switch (resp.status) {
|
|
||||||
case 200:
|
|
||||||
if (iceRestart) {
|
|
||||||
this.updateETag(resp);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// if we are doing an ice restart, we expect 200 OK
|
|
||||||
break;
|
|
||||||
case 204:
|
|
||||||
if (!iceRestart) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// if we are doing trickle ice, we expect 204 No Content
|
|
||||||
break;
|
|
||||||
case 405:
|
|
||||||
case 501:
|
|
||||||
this.logMessage("Trickle ICE not supported, disabling");
|
|
||||||
this.useTrickle = false;
|
|
||||||
break;
|
|
||||||
case 412:
|
|
||||||
this.logMessage("Resource returns 412, session is outdated");
|
|
||||||
this.useTrickle = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const body = await resp.text();
|
|
||||||
throw new Error(`Unexpected status code ${resp.status}: ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
WithEndpoint(endpoint: string, trickle: boolean) {
|
|
||||||
if (endpoint === "") {
|
|
||||||
throw new Error("Endpoint cannot be empty");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = new URL(endpoint);
|
|
||||||
this.logMessage(`Using ${parsed.toString()} as the WHIP/WHEP Endpoint`);
|
|
||||||
this.useTrickle = trickle;
|
|
||||||
this.logMessage(`${trickle ? "Enabling" : "Disabling"} trickle ICE`);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error("Invalid Endpoint URL");
|
|
||||||
}
|
|
||||||
this.endpoint = endpoint;
|
|
||||||
this.resourceURL = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async Disconnect() {
|
|
||||||
this.endpoint = "";
|
|
||||||
this.killConnection();
|
|
||||||
if (!this.resourceURL) {
|
|
||||||
throw new Error("No resource URL");
|
|
||||||
}
|
|
||||||
const resp = await fetch(this.resourceURL, {
|
|
||||||
method: "DELETE",
|
|
||||||
mode: "cors",
|
|
||||||
});
|
|
||||||
if (resp.status !== 200) {
|
|
||||||
const body = await resp.text();
|
|
||||||
throw new Error(`Unexpected status code ${resp.status}: ${body}`);
|
|
||||||
}
|
|
||||||
this.logMessage(`----- Disconnected via DELETE -----`);
|
|
||||||
this.resourceURL = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async Play(): Promise<MediaStream> {
|
|
||||||
this.mode = Mode.Player;
|
|
||||||
this.killConnection();
|
|
||||||
this.playerMedia = new MediaStream();
|
|
||||||
this.createConnection();
|
|
||||||
await this.whepClientOffer();
|
|
||||||
await this.connectedPromise;
|
|
||||||
return this.playerMedia;
|
|
||||||
}
|
|
||||||
|
|
||||||
async Publish(src: MediaStream) {
|
|
||||||
this.mode = Mode.Publisher;
|
|
||||||
this.killConnection();
|
|
||||||
this.createConnection();
|
|
||||||
await this.whipOffer(src);
|
|
||||||
await this.connectedPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async ReplaceVideoTrack(src: MediaStream) {
|
|
||||||
if (!this.videoSender) {
|
|
||||||
throw new Error("Publisher is not active");
|
|
||||||
}
|
|
||||||
const tracks = src.getTracks();
|
|
||||||
if (tracks.length < 1) {
|
|
||||||
throw new Error("No tracks in MediaStream");
|
|
||||||
}
|
|
||||||
return await this.videoSender.replaceTrack(tracks[0]);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,65 +0,0 @@
|
|||||||
// adopted from https://github.com/thlorenz/parse-link-header
|
|
||||||
function parseLink(link: string): Link | null {
|
|
||||||
const matches = link.match(/<?([^>]*)>(.*)/);
|
|
||||||
if (!matches) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const linkUrl = matches[1];
|
|
||||||
const parts = matches[2].split(";");
|
|
||||||
const parsedUrl = new URL(linkUrl);
|
|
||||||
const qs = parsedUrl.searchParams;
|
|
||||||
|
|
||||||
parts.shift();
|
|
||||||
|
|
||||||
const initial: Link = { rel: "", url: linkUrl };
|
|
||||||
const reduced = parts.reduce((acc: Link, p) => {
|
|
||||||
const m = p.match(/\s*(.+)\s*=\s*"?([^"]+)"?/);
|
|
||||||
if (m) {
|
|
||||||
acc[m[1]] = m[2];
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, initial);
|
|
||||||
|
|
||||||
if (!reduced.rel) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
qs.forEach((v, k) => {
|
|
||||||
reduced[k] = v;
|
|
||||||
});
|
|
||||||
|
|
||||||
return reduced;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://stackoverflow.com/a/46700791
|
|
||||||
function notEmpty<T>(value: T | null | undefined): value is T {
|
|
||||||
if (value === null || value === undefined) return false;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const testDummy: T = value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Link {
|
|
||||||
rel: string;
|
|
||||||
url: string;
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Links {
|
|
||||||
[key: string]: Link;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parserLinkHeader(links: string): Links {
|
|
||||||
return links
|
|
||||||
.split(/,\s*</)
|
|
||||||
.map(parseLink)
|
|
||||||
.filter(notEmpty)
|
|
||||||
.reduce((links, l) => {
|
|
||||||
links[l.rel] = l;
|
|
||||||
return links;
|
|
||||||
}, {} as Links);
|
|
||||||
}
|
|
34
yarn.lock
34
yarn.lock
@ -2651,14 +2651,14 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@snort/system-react@npm:^1.5.0":
|
"@snort/system-react@npm:^1.5.1":
|
||||||
version: 1.5.0
|
version: 1.5.1
|
||||||
resolution: "@snort/system-react@npm:1.5.0"
|
resolution: "@snort/system-react@npm:1.5.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@snort/shared": "npm:^1.0.17"
|
"@snort/shared": "npm:^1.0.17"
|
||||||
"@snort/system": "npm:^1.5.0"
|
"@snort/system": "npm:^1.5.1"
|
||||||
react: "npm:^18.2.0"
|
react: "npm:^18.2.0"
|
||||||
checksum: 10c0/3846058d9186531b13e803c34b3716221cf3c7af4c1efc9f3d82ce869ae26639a85361d024496987c3a1d4d1f32dfd38549a257c7076ea308a2f3b8b37d48da2
|
checksum: 10c0/62d55d928f5ef22081e93a96fcff4f30b599a84b67396c93687f51ffeae8cb7ca578b00d033c0e7f731182669f8ae47c86b4497500ace9cdc197d19d4caecb62
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -2669,9 +2669,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@snort/system@npm:^1.5.0":
|
"@snort/system@npm:^1.5.1":
|
||||||
version: 1.5.0
|
version: 1.5.1
|
||||||
resolution: "@snort/system@npm:1.5.0"
|
resolution: "@snort/system@npm:1.5.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@noble/ciphers": "npm:^0.6.0"
|
"@noble/ciphers": "npm:^0.6.0"
|
||||||
"@noble/curves": "npm:^1.4.0"
|
"@noble/curves": "npm:^1.4.0"
|
||||||
@ -2687,22 +2687,22 @@ __metadata:
|
|||||||
lru-cache: "npm:^10.2.0"
|
lru-cache: "npm:^10.2.0"
|
||||||
uuid: "npm:^9.0.0"
|
uuid: "npm:^9.0.0"
|
||||||
ws: "npm:^8.14.0"
|
ws: "npm:^8.14.0"
|
||||||
checksum: 10c0/bd6528b36e6ca9e387bd97c085e4c074896932b7672aabbd7019d5f8b88dd18c78c5335fdde5d95f97beab40da9742343c353e693edf1a36d62fbd6ccacf7671
|
checksum: 10c0/e682b0b739b2d2d5177e37071d7ef2c71dcff42677dba149ac1271596504de2ddbda6c7e8fc3eee3cf2fc175c00cd97fb0cbdf40a96311b59a9e8029e15f03b9
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@snort/wallet@npm:^0.1.8":
|
"@snort/wallet@npm:^0.2.0":
|
||||||
version: 0.1.8
|
version: 0.2.0
|
||||||
resolution: "@snort/wallet@npm:0.1.8"
|
resolution: "@snort/wallet@npm:0.2.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@cashu/cashu-ts": "npm:^1.0.0-rc.3"
|
"@cashu/cashu-ts": "npm:^1.0.0-rc.3"
|
||||||
"@lightninglabs/lnc-web": "npm:^0.3.1-alpha"
|
"@lightninglabs/lnc-web": "npm:^0.3.1-alpha"
|
||||||
"@scure/base": "npm:^1.1.6"
|
"@scure/base": "npm:^1.1.6"
|
||||||
"@snort/shared": "npm:^1.0.17"
|
"@snort/shared": "npm:^1.0.17"
|
||||||
"@snort/system": "npm:^1.5.0"
|
"@snort/system": "npm:^1.5.1"
|
||||||
debug: "npm:^4.3.4"
|
debug: "npm:^4.3.4"
|
||||||
eventemitter3: "npm:^5.0.1"
|
eventemitter3: "npm:^5.0.1"
|
||||||
checksum: 10c0/e655d406f548d78c56d8443edece5b9b79031664911fa23601fb286b7dfceb30b6d52c93456843cef89f3a1bd2898848b5c18ede1a1ee7a67289ba84b85322db
|
checksum: 10c0/3dd6f7d2e4a6c31aace59489ba8086f7f3e347c039cd94be3ee0b2f94ada6d1cc08e2bd9483da1ffc3e54992d757a21e22a11da7cefdc5fd497856421e8f648f
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -7604,10 +7604,10 @@ __metadata:
|
|||||||
"@noble/hashes": "npm:^1.4.0"
|
"@noble/hashes": "npm:^1.4.0"
|
||||||
"@scure/base": "npm:^1.1.6"
|
"@scure/base": "npm:^1.1.6"
|
||||||
"@snort/shared": "npm:^1.0.17"
|
"@snort/shared": "npm:^1.0.17"
|
||||||
"@snort/system": "npm:^1.5.0"
|
"@snort/system": "npm:^1.5.1"
|
||||||
"@snort/system-react": "npm:^1.5.0"
|
"@snort/system-react": "npm:^1.5.1"
|
||||||
"@snort/system-wasm": "npm:^1.0.5"
|
"@snort/system-wasm": "npm:^1.0.5"
|
||||||
"@snort/wallet": "npm:^0.1.8"
|
"@snort/wallet": "npm:^0.2.0"
|
||||||
"@snort/worker-relay": "npm:^1.3.0"
|
"@snort/worker-relay": "npm:^1.3.0"
|
||||||
"@szhsin/react-menu": "npm:^4.1.0"
|
"@szhsin/react-menu": "npm:^4.1.0"
|
||||||
"@testing-library/dom": "npm:^9.3.1"
|
"@testing-library/dom": "npm:^9.3.1"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user