Skip to content

Commit dfb7205

Browse files
committed
init client-web
1 parent 6f83e44 commit dfb7205

26 files changed

+1130
-1240
lines changed
File renamed without changes.

client-web/index.html

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!doctype html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8" />
6+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
7+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
8+
<title>Vite + Lit + TS</title>
9+
<script type="module" src="/src/index.ts"></script>
10+
</head>
11+
12+
<body>
13+
<my-element>
14+
<h1>Vite + Lit</h1>
15+
</my-element>
16+
</body>
17+
18+
</html>

client-web/package.json

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "client-web",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"main": "./dist/pulsebeam.umd.cjs",
7+
"module": "./dist/pulsebeam.es.js",
8+
"types": "./dist/types/index.d.ts",
9+
"exports": {
10+
".": {
11+
"require": {
12+
"types": "./dist/types/index.d.ts",
13+
"default": "./dist/pulsebeam.es.js"
14+
},
15+
"default": {
16+
"types": "./dist/types/index.d.ts",
17+
"default": "./dist/pulsebeam.umd.js"
18+
}
19+
}
20+
},
21+
"scripts": {
22+
"proto": "protoc --ts_out src/lib --proto_path proto proto/sfu.proto",
23+
"dev": "vite",
24+
"build": "tsc && vite build",
25+
"preview": "vite preview"
26+
},
27+
"dependencies": {
28+
"@protobuf-ts/runtime": "^2.10.0",
29+
"lit": "^3.3.0"
30+
},
31+
"devDependencies": {
32+
"@protobuf-ts/plugin": "^2.10.0",
33+
"typescript": "~5.8.3",
34+
"vite": "^6.3.5",
35+
"vite-plugin-dts": "^4.5.4"
36+
}
37+
}

client-web/proto/sfu.proto

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
syntax = "proto3";
2+
3+
package sfu;
4+
5+
// Represents the kind of media track.
6+
enum TrackKind {
7+
TRACK_KIND_UNSPECIFIED = 0;
8+
VIDEO = 1;
9+
AUDIO = 2;
10+
}
11+
12+
// --- Client to Server Messages ---
13+
14+
message ClientSubscribePayload {
15+
string mid = 1; // The client's MID (transceiver slot) to use for this track.
16+
string remote_track_id = 2; // The application-level ID of the remote track to subscribe to.
17+
}
18+
19+
message ClientUnsubscribePayload {
20+
string mid = 1; // The client's MID (transceiver slot) to unsubscribe from.
21+
}
22+
23+
// ClientMessage encapsulates all possible messages from client to SFU.
24+
message ClientMessage {
25+
oneof payload {
26+
ClientSubscribePayload subscribe = 1;
27+
ClientUnsubscribePayload unsubscribe = 2;
28+
}
29+
}
30+
31+
32+
// --- Server to Client Messages ---
33+
34+
message TrackInfo {
35+
string track_id = 1; // The ID of the newly available remote track.
36+
TrackKind kind = 2; // The kind of track.
37+
string participant_id = 3; // The ID of the participant who published this track.
38+
// map<string, string> metadata = 4; // Optional: any other app-specific metadata about the track.
39+
}
40+
41+
message TrackSwitchInfo {
42+
string mid = 2; // The client's MID that the SFU will use (confirming client's request).
43+
optional TrackInfo remote_track = 3;
44+
}
45+
46+
message TrackPublishedPayload {
47+
TrackInfo remote_track = 1;
48+
}
49+
50+
message TrackUnpublishedPayload {
51+
string remote_track_id = 1; // The ID of the remote track that is no longer available.
52+
}
53+
54+
message TrackSwitchedPayload {
55+
repeated TrackSwitchInfo switches = 1;
56+
}
57+
58+
message ErrorPayload {
59+
string description = 1; // General error message from the SFU.
60+
}
61+
62+
// ServerMessage encapsulates all possible messages from SFU to client.
63+
message ServerMessage {
64+
oneof payload {
65+
ErrorPayload error = 1; // General error from SFU.
66+
TrackPublishedPayload track_published = 2; // SFU informs client a new remote track is available.
67+
TrackUnpublishedPayload track_unpublished = 3; // SFU informs client a remote track is no longer available.
68+
TrackSwitchedPayload track_switched = 4; // SFU confirms track switching for a mid
69+
}
70+
}
File renamed without changes.

client-web/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { MyElement } from "./my-element.ts";

client-web/src/lib/core.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { ClientMessage, ServerMessage } from "./sfu.ts";
2+
3+
const MAX_DOWNSTREAMS = 9;
4+
5+
type MID = string;
6+
7+
export interface ClientCoreConfig {
8+
sfuUrl: string;
9+
maxDownstreams: number;
10+
onStateChanged?: (state: RTCPeerConnectionState) => void;
11+
}
12+
13+
export class ClientCore {
14+
#sfuUrl: string;
15+
#pc: RTCPeerConnection;
16+
#rpc: RTCDataChannel;
17+
#videoSender: RTCRtpTransceiver;
18+
#audioSender: RTCRtpTransceiver;
19+
#closed: boolean;
20+
21+
#videoSlots: Record<MID, RTCRtpTransceiver>;
22+
#audioSlots: Record<MID, RTCRtpTransceiver>;
23+
24+
constructor(cfg: ClientCoreConfig) {
25+
this.#sfuUrl = cfg.sfuUrl;
26+
const maxDownstreams = Math.max(
27+
Math.min(cfg.maxDownstreams, MAX_DOWNSTREAMS),
28+
0,
29+
);
30+
const onStateChanged = cfg.onStateChanged || (() => {});
31+
this.#closed = false;
32+
this.#videoSlots = {};
33+
this.#audioSlots = {};
34+
35+
this.#pc = new RTCPeerConnection();
36+
this.#pc.onconnectionstatechange = () => {
37+
const connectionState = this.#pc.connectionState;
38+
console.debug(`PeerConnection state changed: ${connectionState}`);
39+
if (connectionState === "connected") {
40+
onStateChanged(connectionState);
41+
} else if (
42+
connectionState === "failed" || connectionState === "closed" ||
43+
connectionState === "disconnected"
44+
) {
45+
this.#close(
46+
`PeerConnection state became: ${connectionState}`,
47+
);
48+
}
49+
};
50+
51+
this.#pc.ontrack = (event: RTCTrackEvent) => {
52+
const mid = event.transceiver?.mid;
53+
const track = event.track;
54+
if (!mid || !track) {
55+
console.warn("Received track event without MID or track object.");
56+
return;
57+
}
58+
59+
// TODO: implement this
60+
};
61+
62+
// SFU RPC DataChannel
63+
this.#rpc = this.#pc.createDataChannel("pulsebeam::rpc");
64+
this.#rpc.binaryType = "arraybuffer";
65+
this.#rpc.onmessage = (event: MessageEvent) => {
66+
try {
67+
const serverMessage = ServerMessage.fromBinary(
68+
new Uint8Array(event.data as ArrayBuffer),
69+
);
70+
const payload = serverMessage.payload;
71+
const payloadKind = payload.oneofKind;
72+
if (!payloadKind) {
73+
console.warn("Received SFU message with undefined payload kind.");
74+
return;
75+
}
76+
77+
// TODO: implement this
78+
} catch (e: any) {
79+
this.#close(`Error processing SFU RPC message: ${e}`);
80+
}
81+
};
82+
this.#rpc.onclose = () => {
83+
this.#close("Internal RPC closed prematurely");
84+
};
85+
this.#rpc.onerror = (e) => {
86+
this.#close(`Internal RPC closed prematurely with an error: ${e}`);
87+
};
88+
89+
// Transceivers
90+
this.#videoSender = this.#pc.addTransceiver("video", {
91+
direction: "sendonly",
92+
});
93+
this.#audioSender = this.#pc.addTransceiver("audio", {
94+
direction: "sendonly",
95+
});
96+
for (let i = 0; i < maxDownstreams; i++) {
97+
const videoTransceiver = this.#pc.addTransceiver("video", {
98+
direction: "recvonly",
99+
});
100+
if (!videoTransceiver.mid) {
101+
this.#close("missing mid from video recvonly");
102+
return;
103+
}
104+
105+
this.#videoSlots[videoTransceiver.mid] = videoTransceiver;
106+
const audioTransceiver = this.#pc.addTransceiver("audio", {
107+
direction: "recvonly",
108+
});
109+
110+
if (!audioTransceiver.mid) {
111+
this.#close("missing mid from audio recvonly");
112+
return;
113+
}
114+
this.#audioSlots[audioTransceiver.mid] = audioTransceiver;
115+
}
116+
}
117+
118+
#close(error?: string) {
119+
if (this.#closed) return;
120+
121+
if (error) {
122+
console.error("exited with an error:", error);
123+
}
124+
125+
this.#closed = true;
126+
}
127+
128+
async connect(room: string, participant: string) {
129+
if (this.#closed) {
130+
const errorMessage =
131+
"This client instance has been terminated and cannot be reused.";
132+
console.error(errorMessage);
133+
throw new Error(errorMessage); // More direct feedback to developer
134+
}
135+
136+
try {
137+
const offer = await this.#pc.createOffer();
138+
await this.#pc.setLocalDescription(offer);
139+
const response = await fetch(
140+
`${this.#sfuUrl}?room=${room}&participant=${participant}`,
141+
{
142+
method: "POST",
143+
body: offer.sdp!,
144+
headers: { "Content-Type": "application/sdp" },
145+
},
146+
);
147+
if (!response.ok) {
148+
throw new Error(
149+
`Signaling request failed: ${response.status} ${await response
150+
.text()}`,
151+
);
152+
}
153+
await this.#pc.setRemoteDescription({
154+
type: "answer",
155+
sdp: await response.text(),
156+
});
157+
// Status transitions to "connected" will be handled by onconnectionstatechange and data channel onopen events.
158+
} catch (error: any) {
159+
this.#close(
160+
error.message || "Signaling process failed unexpectedly.",
161+
);
162+
}
163+
}
164+
165+
disconnect() {
166+
this.#pc.close();
167+
}
168+
169+
publish(stream: MediaStream) {
170+
const videoTracks = stream.getVideoTracks();
171+
if (videoTracks.length > 1) {
172+
throw new Error(
173+
`Unexpected MediaStream composition: Expected at most one video track, but found ${videoTracks.length}. This component or function is designed to handle a single video source and/or a single audio source.`,
174+
);
175+
}
176+
177+
const audioTracks = stream.getAudioTracks();
178+
if (audioTracks.length > 1) {
179+
throw new Error(
180+
`Unexpected MediaStream composition: Expected at most one audio track, but found ${audioTracks.length}. This component or function is designed to handle a single audio source and/or a single audio source.`,
181+
);
182+
}
183+
184+
const newVideoTrack = videoTracks.at(0) || null;
185+
this.#videoSender.sender.replaceTrack(newVideoTrack);
186+
187+
const newAudioTrack = audioTracks.at(0) || null;
188+
this.#audioSender.sender.replaceTrack(newAudioTrack);
189+
}
190+
}
File renamed without changes.

0 commit comments

Comments
 (0)