sabha-meetings-react 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -146,10 +146,13 @@ type UpdateMeetingInput = {
146
146
  [key: string]: unknown;
147
147
  };
148
148
  type MeetingConnectionState = "disconnected" | "connecting" | "connected" | "joined" | "error";
149
+ /** Host publishes A/V; participants join to view the host only. */
150
+ type MeetingRole = "host" | "participant";
149
151
  type MeetingParticipant = {
150
152
  id: string;
151
153
  displayName?: string;
152
154
  username?: string;
155
+ role?: MeetingRole;
153
156
  audioEnabled?: boolean;
154
157
  videoEnabled?: boolean;
155
158
  screenSharing?: boolean;
@@ -188,6 +191,8 @@ type MeetingRoomEventMap = {
188
191
  reason?: string;
189
192
  };
190
193
  mediaState: LocalMediaState;
194
+ /** Host media received by a participant (one-way). */
195
+ remoteStream: MediaStream | null;
191
196
  captureAttempt: {
192
197
  type: "screenshot" | "recording" | "visibility";
193
198
  at: string;
@@ -269,6 +274,10 @@ type MeetingConnectionOptions = {
269
274
  socketPath?: string;
270
275
  events?: Partial<SabhaSocketEvents>;
271
276
  mediaPolicy?: Partial<MediaPolicy>;
277
+ /** Host publishes A/V; participant receives host stream only. Default host. */
278
+ role?: MeetingRole;
279
+ /** Stable id for WebRTC signaling (usually Sabha userId). */
280
+ selfId?: string;
272
281
  };
273
282
  declare class MeetingConnection {
274
283
  private readonly options;
@@ -276,15 +285,21 @@ declare class MeetingConnection {
276
285
  private state;
277
286
  private readonly events;
278
287
  private readonly mediaPolicy;
288
+ private readonly role;
289
+ private readonly selfId;
279
290
  readonly media: MediaController;
280
291
  readonly protection: ContentProtection;
281
292
  private readonly listeners;
282
293
  private mediaUnsub;
283
294
  private captureUnsub;
295
+ private readonly peers;
296
+ private remoteStream;
284
297
  constructor(options: MeetingConnectionOptions);
285
298
  get meetingId(): string;
299
+ get meetingRole(): MeetingRole;
286
300
  get connectionState(): MeetingConnectionState;
287
301
  getMediaState(): LocalMediaState;
302
+ getRemoteStream(): MediaStream | null;
288
303
  on<K extends keyof MeetingRoomEventMap>(event: K, listener: Listener<MeetingRoomEventMap[K]>): () => void;
289
304
  connect(): Promise<void>;
290
305
  setAudioEnabled(enabled: boolean): Promise<LocalMediaState>;
@@ -298,6 +313,13 @@ declare class MeetingConnection {
298
313
  private teardownMedia;
299
314
  private join;
300
315
  private bindServerEvents;
316
+ private sendSignal;
317
+ private handleSignal;
318
+ private createHostOffer;
319
+ private getOrCreatePeer;
320
+ private attachLocalTracks;
321
+ private refreshHostTracks;
322
+ private closePeer;
301
323
  private setState;
302
324
  private emit;
303
325
  }
@@ -344,7 +366,9 @@ declare class SabhaClient {
344
366
  meeting: Meeting;
345
367
  connection: MeetingConnection;
346
368
  }>;
347
- connect(meetingId: string): Promise<MeetingConnection>;
369
+ connect(meetingId: string, options?: {
370
+ role?: MeetingRole;
371
+ }): Promise<MeetingConnection>;
348
372
  getConnection(): MeetingConnection | null;
349
373
  leaveMeeting(): Promise<void>;
350
374
  private applySession;
@@ -407,7 +431,9 @@ declare function useSabhaMeetings(): {
407
431
  meeting: Meeting;
408
432
  connection: MeetingConnection;
409
433
  }>;
410
- connect: (meetingId: string) => Promise<MeetingConnection>;
434
+ connect: (meetingId: string, options?: {
435
+ role?: MeetingRole;
436
+ }) => Promise<MeetingConnection>;
411
437
  leaveMeeting: () => Promise<void>;
412
438
  connection: MeetingConnection | null;
413
439
  connectionState: MeetingConnectionState;
@@ -447,4 +473,4 @@ declare function readStoredSessionCookie(): string | null;
447
473
  declare function writeStoredSessionCookie(value: string | null): void;
448
474
 
449
475
  export { ContentProtection, DEFAULT_BASE_URL, DEFAULT_MEDIA_POLICY, MediaController, MeetingConnection, SABHA_SESSION_HEADER, SABHA_SESSION_STORAGE_KEY, SABHA_TOKENS_STORAGE_KEY, SabhaClient, SabhaError, SabhaProvider, createSabhaClient, normalizeMeeting, readStoredSessionCookie, readStoredTokens, toSabhaError, useSabha, useSabhaMeetings, writeStoredSessionCookie, writeStoredTokens };
450
- export type { AuthSession, AuthUser, CaptureAttemptType, CreateMeetingInput, LocalMediaState, LoginInput, MediaPolicy, MediaTrackKind, Meeting, MeetingConnectionOptions, MeetingConnectionState, MeetingInvitee, MeetingOrganizer, MeetingParticipant, MeetingRoomEventMap, RegisterInput, SabhaClientStatus, SabhaConfig, SabhaContextValue, SabhaOrg, SabhaProviderProps, SabhaSocketEvents, SabhaTokens, UpdateMeetingInput, VerifyEmailInput };
476
+ export type { AuthSession, AuthUser, CaptureAttemptType, CreateMeetingInput, LocalMediaState, LoginInput, MediaPolicy, MediaTrackKind, Meeting, MeetingConnectionOptions, MeetingConnectionState, MeetingInvitee, MeetingOrganizer, MeetingParticipant, MeetingRole, MeetingRoomEventMap, RegisterInput, SabhaClientStatus, SabhaConfig, SabhaContextValue, SabhaOrg, SabhaProviderProps, SabhaSocketEvents, SabhaTokens, UpdateMeetingInput, VerifyEmailInput };
package/dist/index.d.ts CHANGED
@@ -146,10 +146,13 @@ type UpdateMeetingInput = {
146
146
  [key: string]: unknown;
147
147
  };
148
148
  type MeetingConnectionState = "disconnected" | "connecting" | "connected" | "joined" | "error";
149
+ /** Host publishes A/V; participants join to view the host only. */
150
+ type MeetingRole = "host" | "participant";
149
151
  type MeetingParticipant = {
150
152
  id: string;
151
153
  displayName?: string;
152
154
  username?: string;
155
+ role?: MeetingRole;
153
156
  audioEnabled?: boolean;
154
157
  videoEnabled?: boolean;
155
158
  screenSharing?: boolean;
@@ -188,6 +191,8 @@ type MeetingRoomEventMap = {
188
191
  reason?: string;
189
192
  };
190
193
  mediaState: LocalMediaState;
194
+ /** Host media received by a participant (one-way). */
195
+ remoteStream: MediaStream | null;
191
196
  captureAttempt: {
192
197
  type: "screenshot" | "recording" | "visibility";
193
198
  at: string;
@@ -269,6 +274,10 @@ type MeetingConnectionOptions = {
269
274
  socketPath?: string;
270
275
  events?: Partial<SabhaSocketEvents>;
271
276
  mediaPolicy?: Partial<MediaPolicy>;
277
+ /** Host publishes A/V; participant receives host stream only. Default host. */
278
+ role?: MeetingRole;
279
+ /** Stable id for WebRTC signaling (usually Sabha userId). */
280
+ selfId?: string;
272
281
  };
273
282
  declare class MeetingConnection {
274
283
  private readonly options;
@@ -276,15 +285,21 @@ declare class MeetingConnection {
276
285
  private state;
277
286
  private readonly events;
278
287
  private readonly mediaPolicy;
288
+ private readonly role;
289
+ private readonly selfId;
279
290
  readonly media: MediaController;
280
291
  readonly protection: ContentProtection;
281
292
  private readonly listeners;
282
293
  private mediaUnsub;
283
294
  private captureUnsub;
295
+ private readonly peers;
296
+ private remoteStream;
284
297
  constructor(options: MeetingConnectionOptions);
285
298
  get meetingId(): string;
299
+ get meetingRole(): MeetingRole;
286
300
  get connectionState(): MeetingConnectionState;
287
301
  getMediaState(): LocalMediaState;
302
+ getRemoteStream(): MediaStream | null;
288
303
  on<K extends keyof MeetingRoomEventMap>(event: K, listener: Listener<MeetingRoomEventMap[K]>): () => void;
289
304
  connect(): Promise<void>;
290
305
  setAudioEnabled(enabled: boolean): Promise<LocalMediaState>;
@@ -298,6 +313,13 @@ declare class MeetingConnection {
298
313
  private teardownMedia;
299
314
  private join;
300
315
  private bindServerEvents;
316
+ private sendSignal;
317
+ private handleSignal;
318
+ private createHostOffer;
319
+ private getOrCreatePeer;
320
+ private attachLocalTracks;
321
+ private refreshHostTracks;
322
+ private closePeer;
301
323
  private setState;
302
324
  private emit;
303
325
  }
@@ -344,7 +366,9 @@ declare class SabhaClient {
344
366
  meeting: Meeting;
345
367
  connection: MeetingConnection;
346
368
  }>;
347
- connect(meetingId: string): Promise<MeetingConnection>;
369
+ connect(meetingId: string, options?: {
370
+ role?: MeetingRole;
371
+ }): Promise<MeetingConnection>;
348
372
  getConnection(): MeetingConnection | null;
349
373
  leaveMeeting(): Promise<void>;
350
374
  private applySession;
@@ -407,7 +431,9 @@ declare function useSabhaMeetings(): {
407
431
  meeting: Meeting;
408
432
  connection: MeetingConnection;
409
433
  }>;
410
- connect: (meetingId: string) => Promise<MeetingConnection>;
434
+ connect: (meetingId: string, options?: {
435
+ role?: MeetingRole;
436
+ }) => Promise<MeetingConnection>;
411
437
  leaveMeeting: () => Promise<void>;
412
438
  connection: MeetingConnection | null;
413
439
  connectionState: MeetingConnectionState;
@@ -447,4 +473,4 @@ declare function readStoredSessionCookie(): string | null;
447
473
  declare function writeStoredSessionCookie(value: string | null): void;
448
474
 
449
475
  export { ContentProtection, DEFAULT_BASE_URL, DEFAULT_MEDIA_POLICY, MediaController, MeetingConnection, SABHA_SESSION_HEADER, SABHA_SESSION_STORAGE_KEY, SABHA_TOKENS_STORAGE_KEY, SabhaClient, SabhaError, SabhaProvider, createSabhaClient, normalizeMeeting, readStoredSessionCookie, readStoredTokens, toSabhaError, useSabha, useSabhaMeetings, writeStoredSessionCookie, writeStoredTokens };
450
- export type { AuthSession, AuthUser, CaptureAttemptType, CreateMeetingInput, LocalMediaState, LoginInput, MediaPolicy, MediaTrackKind, Meeting, MeetingConnectionOptions, MeetingConnectionState, MeetingInvitee, MeetingOrganizer, MeetingParticipant, MeetingRoomEventMap, RegisterInput, SabhaClientStatus, SabhaConfig, SabhaContextValue, SabhaOrg, SabhaProviderProps, SabhaSocketEvents, SabhaTokens, UpdateMeetingInput, VerifyEmailInput };
476
+ export type { AuthSession, AuthUser, CaptureAttemptType, CreateMeetingInput, LocalMediaState, LoginInput, MediaPolicy, MediaTrackKind, Meeting, MeetingConnectionOptions, MeetingConnectionState, MeetingInvitee, MeetingOrganizer, MeetingParticipant, MeetingRole, MeetingRoomEventMap, RegisterInput, SabhaClientStatus, SabhaConfig, SabhaContextValue, SabhaOrg, SabhaProviderProps, SabhaSocketEvents, SabhaTokens, UpdateMeetingInput, VerifyEmailInput };
package/dist/index.js CHANGED
@@ -681,6 +681,11 @@ var DEFAULT_EVENTS = {
681
681
  error: "meeting:error",
682
682
  mediaState: "meeting:media-state"
683
683
  };
684
+ var SIGNAL_EVENT = "meeting:signal";
685
+ var ICE_SERVERS = [
686
+ { urls: "stun:stun.l.google.com:19302" },
687
+ { urls: "stun:stun1.l.google.com:19302" }
688
+ ];
684
689
  var MeetingConnection = class {
685
690
  constructor(options) {
686
691
  this.options = options;
@@ -689,20 +694,37 @@ var MeetingConnection = class {
689
694
  this.listeners = /* @__PURE__ */ new Map();
690
695
  this.mediaUnsub = null;
691
696
  this.captureUnsub = null;
697
+ this.peers = /* @__PURE__ */ new Map();
698
+ this.remoteStream = null;
692
699
  this.events = { ...DEFAULT_EVENTS, ...options.events };
693
- this.mediaPolicy = { ...DEFAULT_MEDIA_POLICY, ...options.mediaPolicy };
700
+ this.role = options.role ?? "host";
701
+ this.selfId = options.selfId || `anon-${Math.random().toString(36).slice(2, 10)}`;
702
+ this.mediaPolicy = this.role === "participant" ? {
703
+ ...DEFAULT_MEDIA_POLICY,
704
+ ...options.mediaPolicy,
705
+ audio: false,
706
+ video: false,
707
+ allowScreenShare: false,
708
+ screenOff: true
709
+ } : { ...DEFAULT_MEDIA_POLICY, ...options.mediaPolicy };
694
710
  this.media = new MediaController(this.mediaPolicy);
695
711
  this.protection = new ContentProtection(this.mediaPolicy);
696
712
  }
697
713
  get meetingId() {
698
714
  return this.options.meetingId;
699
715
  }
716
+ get meetingRole() {
717
+ return this.role;
718
+ }
700
719
  get connectionState() {
701
720
  return this.state;
702
721
  }
703
722
  getMediaState() {
704
723
  return this.media.getMediaState();
705
724
  }
725
+ getRemoteStream() {
726
+ return this.remoteStream;
727
+ }
706
728
  on(event, listener) {
707
729
  const set = this.listeners.get(event) ?? /* @__PURE__ */ new Set();
708
730
  set.add(listener);
@@ -734,7 +756,9 @@ var MeetingConnection = class {
734
756
  withCredentials: this.options.withCredentials !== false,
735
757
  auth,
736
758
  extraHeaders,
737
- autoConnect: true
759
+ autoConnect: true,
760
+ // Leave must stay left — do not let socket.io reconnect the room.
761
+ reconnection: false
738
762
  });
739
763
  this.socket = socket;
740
764
  const onConnect = () => {
@@ -749,11 +773,22 @@ var MeetingConnection = class {
749
773
  meetingId: this.options.meetingId,
750
774
  ...mediaState
751
775
  });
776
+ if (this.role === "host") {
777
+ void this.refreshHostTracks();
778
+ }
752
779
  });
753
780
  this.captureUnsub = this.protection.onCaptureAttempt((event) => {
754
781
  this.emit("captureAttempt", event);
755
782
  });
756
- await this.media.start(this.mediaPolicy);
783
+ if (this.role === "host") {
784
+ await this.media.start(this.mediaPolicy);
785
+ } else {
786
+ this.sendSignal({
787
+ type: "request-offer",
788
+ from: this.selfId,
789
+ role: "participant"
790
+ });
791
+ }
757
792
  resolve();
758
793
  }).catch(reject);
759
794
  };
@@ -777,24 +812,56 @@ var MeetingConnection = class {
777
812
  });
778
813
  }
779
814
  async setAudioEnabled(enabled) {
815
+ if (this.role !== "host") {
816
+ throw new SabhaError("Only the host can control the microphone", {
817
+ code: "HOST_ONLY"
818
+ });
819
+ }
780
820
  return this.media.setAudioEnabled(enabled);
781
821
  }
782
822
  async setVideoEnabled(enabled) {
823
+ if (this.role !== "host") {
824
+ throw new SabhaError("Only the host can control the camera", {
825
+ code: "HOST_ONLY"
826
+ });
827
+ }
783
828
  return this.media.setVideoEnabled(enabled);
784
829
  }
785
830
  async setScreenOff(off) {
831
+ if (this.role !== "host") {
832
+ throw new SabhaError("Only the host can change screen settings", {
833
+ code: "HOST_ONLY"
834
+ });
835
+ }
786
836
  return this.media.setScreenOff(off);
787
837
  }
788
838
  async startScreenShare() {
839
+ if (this.role !== "host") {
840
+ throw new SabhaError("Only the host can share screen", {
841
+ code: "HOST_ONLY"
842
+ });
843
+ }
789
844
  return this.media.startScreenShare();
790
845
  }
791
846
  async stopScreenShare() {
847
+ if (this.role !== "host") {
848
+ throw new SabhaError("Only the host can stop screen share", {
849
+ code: "HOST_ONLY"
850
+ });
851
+ }
792
852
  return this.media.stopScreenShare();
793
853
  }
794
854
  async leave() {
795
855
  const socket = this.socket;
796
856
  this.teardownMedia();
797
- if (!socket) return;
857
+ if (!socket) {
858
+ this.setState("disconnected");
859
+ return;
860
+ }
861
+ try {
862
+ socket.io.reconnection(false);
863
+ } catch {
864
+ }
798
865
  await new Promise((resolve) => {
799
866
  socket.timeout(5e3).emit(
800
867
  this.events.leave,
@@ -803,13 +870,22 @@ var MeetingConnection = class {
803
870
  );
804
871
  setTimeout(() => resolve(), 500);
805
872
  });
873
+ socket.removeAllListeners();
806
874
  socket.disconnect();
807
875
  this.socket = null;
808
876
  this.setState("disconnected");
809
877
  }
810
878
  disconnect() {
811
879
  this.teardownMedia();
812
- this.socket?.disconnect();
880
+ const socket = this.socket;
881
+ if (socket) {
882
+ try {
883
+ socket.io.reconnection(false);
884
+ } catch {
885
+ }
886
+ socket.removeAllListeners();
887
+ socket.disconnect();
888
+ }
813
889
  this.socket = null;
814
890
  this.setState("disconnected");
815
891
  }
@@ -826,6 +902,10 @@ var MeetingConnection = class {
826
902
  this.captureUnsub?.();
827
903
  this.mediaUnsub = null;
828
904
  this.captureUnsub = null;
905
+ for (const peer of this.peers.values()) peer.close();
906
+ this.peers.clear();
907
+ this.remoteStream = null;
908
+ this.emit("remoteStream", null);
829
909
  this.media.stopAll();
830
910
  this.protection.disable();
831
911
  }
@@ -846,7 +926,11 @@ var MeetingConnection = class {
846
926
  };
847
927
  socket.timeout(5e3).emit(
848
928
  this.events.join,
849
- { meetingId: this.options.meetingId },
929
+ {
930
+ meetingId: this.options.meetingId,
931
+ role: this.role,
932
+ peerId: this.selfId
933
+ },
850
934
  () => done()
851
935
  );
852
936
  setTimeout(done, 600);
@@ -856,10 +940,19 @@ var MeetingConnection = class {
856
940
  socket.on(this.events.participantJoined, (payload) => {
857
941
  this.emit("participantJoined", payload);
858
942
  this.emit("raw", { event: this.events.participantJoined, payload });
943
+ if (this.role === "host") {
944
+ const peerId = payload.id;
945
+ if (peerId && peerId !== this.selfId) {
946
+ void this.createHostOffer(peerId).catch((error) => {
947
+ this.emit("error", toError(error, "Failed to create host offer"));
948
+ });
949
+ }
950
+ }
859
951
  });
860
952
  socket.on(this.events.participantLeft, (payload) => {
861
953
  this.emit("participantLeft", payload);
862
954
  this.emit("raw", { event: this.events.participantLeft, payload });
955
+ this.closePeer(payload.id);
863
956
  });
864
957
  socket.on(
865
958
  this.events.meetingEnded,
@@ -881,10 +974,135 @@ var MeetingConnection = class {
881
974
  this.emit("error", error);
882
975
  this.emit("raw", { event: this.events.error, payload });
883
976
  });
977
+ socket.on(SIGNAL_EVENT, (payload) => {
978
+ void this.handleSignal(payload).catch((error) => {
979
+ this.emit("error", toError(error, "WebRTC signal failed"));
980
+ });
981
+ });
884
982
  socket.on("disconnect", () => {
885
983
  this.setState("disconnected");
886
984
  });
887
985
  }
986
+ sendSignal(partial) {
987
+ if (!this.socket?.connected) return;
988
+ this.socket.emit(SIGNAL_EVENT, {
989
+ meetingId: this.options.meetingId,
990
+ ...partial
991
+ });
992
+ }
993
+ async handleSignal(payload) {
994
+ if (!payload || payload.meetingId !== this.options.meetingId) return;
995
+ if (payload.from === this.selfId) return;
996
+ if (payload.to && payload.to !== this.selfId) return;
997
+ if (payload.type === "request-offer" && this.role === "host") {
998
+ await this.createHostOffer(payload.from);
999
+ return;
1000
+ }
1001
+ if (payload.type === "offer" && this.role === "participant") {
1002
+ const peer = this.getOrCreatePeer(payload.from, false);
1003
+ await peer.setRemoteDescription(payload.sdp);
1004
+ const answer = await peer.createAnswer();
1005
+ await peer.setLocalDescription(answer);
1006
+ this.sendSignal({
1007
+ type: "answer",
1008
+ from: this.selfId,
1009
+ to: payload.from,
1010
+ sdp: answer,
1011
+ role: "participant"
1012
+ });
1013
+ return;
1014
+ }
1015
+ if (payload.type === "answer" && this.role === "host") {
1016
+ const peer = this.peers.get(payload.from);
1017
+ if (!peer) return;
1018
+ await peer.setRemoteDescription(payload.sdp);
1019
+ return;
1020
+ }
1021
+ if (payload.type === "ice") {
1022
+ const peer = this.peers.get(payload.from);
1023
+ if (!peer || !payload.candidate) return;
1024
+ await peer.addIceCandidate(payload.candidate);
1025
+ }
1026
+ }
1027
+ async createHostOffer(peerId) {
1028
+ const peer = this.getOrCreatePeer(peerId, true);
1029
+ await this.attachLocalTracks(peer);
1030
+ const offer = await peer.createOffer({
1031
+ offerToReceiveAudio: false,
1032
+ offerToReceiveVideo: false
1033
+ });
1034
+ await peer.setLocalDescription(offer);
1035
+ this.sendSignal({
1036
+ type: "offer",
1037
+ from: this.selfId,
1038
+ to: peerId,
1039
+ sdp: offer,
1040
+ role: "host"
1041
+ });
1042
+ }
1043
+ getOrCreatePeer(peerId, isHost) {
1044
+ const existing = this.peers.get(peerId);
1045
+ if (existing) return existing;
1046
+ const peer = new RTCPeerConnection({ iceServers: ICE_SERVERS });
1047
+ this.peers.set(peerId, peer);
1048
+ peer.onicecandidate = (event) => {
1049
+ this.sendSignal({
1050
+ type: "ice",
1051
+ from: this.selfId,
1052
+ to: peerId,
1053
+ candidate: event.candidate ? event.candidate.toJSON() : null,
1054
+ role: this.role
1055
+ });
1056
+ };
1057
+ peer.onconnectionstatechange = () => {
1058
+ if (peer.connectionState === "failed" || peer.connectionState === "closed" || peer.connectionState === "disconnected") ;
1059
+ };
1060
+ if (!isHost) {
1061
+ peer.ontrack = (event) => {
1062
+ const stream = event.streams[0] ?? new MediaStream([event.track]);
1063
+ this.remoteStream = stream;
1064
+ this.emit("remoteStream", stream);
1065
+ };
1066
+ }
1067
+ return peer;
1068
+ }
1069
+ async attachLocalTracks(peer) {
1070
+ const local = new MediaStream();
1071
+ const cam = this.media.getCameraStream();
1072
+ const mic = this.media.getMicrophoneStream();
1073
+ const screen = this.media.getScreenStream();
1074
+ mic?.getAudioTracks().forEach((track) => local.addTrack(track));
1075
+ const videoSource = screen ?? cam;
1076
+ videoSource?.getVideoTracks().forEach((track) => local.addTrack(track));
1077
+ const senders = peer.getSenders();
1078
+ for (const track of local.getTracks()) {
1079
+ const already = senders.some((s) => s.track?.id === track.id);
1080
+ if (!already) peer.addTrack(track, local);
1081
+ }
1082
+ }
1083
+ async refreshHostTracks() {
1084
+ for (const [peerId, peer] of this.peers) {
1085
+ try {
1086
+ await this.attachLocalTracks(peer);
1087
+ const offer = await peer.createOffer();
1088
+ await peer.setLocalDescription(offer);
1089
+ this.sendSignal({
1090
+ type: "offer",
1091
+ from: this.selfId,
1092
+ to: peerId,
1093
+ sdp: offer,
1094
+ role: "host"
1095
+ });
1096
+ } catch {
1097
+ }
1098
+ }
1099
+ }
1100
+ closePeer(peerId) {
1101
+ const peer = this.peers.get(peerId);
1102
+ if (!peer) return;
1103
+ peer.close();
1104
+ this.peers.delete(peerId);
1105
+ }
888
1106
  setState(state) {
889
1107
  this.state = state;
890
1108
  this.emit("state", state);
@@ -895,6 +1113,10 @@ var MeetingConnection = class {
895
1113
  for (const listener of set) listener(payload);
896
1114
  }
897
1115
  };
1116
+ function toError(error, fallback) {
1117
+ if (error instanceof Error) return error;
1118
+ return new Error(fallback);
1119
+ }
898
1120
 
899
1121
  // src/SabhaClient.ts
900
1122
  var SabhaClient = class {
@@ -1110,15 +1332,16 @@ var SabhaClient = class {
1110
1332
  groupId: input?.groupId
1111
1333
  });
1112
1334
  }
1113
- const connection = await this.connect(meeting.id);
1335
+ const connection = await this.connect(meeting.id, { role: "host" });
1114
1336
  return { meeting, connection };
1115
1337
  }
1116
- async connect(meetingId) {
1338
+ async connect(meetingId, options) {
1117
1339
  this.ensureConfigured();
1118
1340
  if (this.activeConnection) {
1119
1341
  this.activeConnection.disconnect();
1120
1342
  this.activeConnection = null;
1121
1343
  }
1344
+ const role = options?.role ?? "host";
1122
1345
  const connection = new MeetingConnection({
1123
1346
  baseUrl: this.config.baseUrl ?? DEFAULT_BASE_URL,
1124
1347
  meetingId,
@@ -1127,7 +1350,10 @@ var SabhaClient = class {
1127
1350
  withCredentials: this.config.withCredentials !== false,
1128
1351
  socketPath: this.config.socketPath,
1129
1352
  events: this.config.socketEvents,
1130
- mediaPolicy: this.mediaPolicy
1353
+ mediaPolicy: this.mediaPolicy,
1354
+ role,
1355
+ // Per-browser id so same Sabha account can host on one device and view on another.
1356
+ selfId: getDevicePeerId(this.user?.userId)
1131
1357
  });
1132
1358
  try {
1133
1359
  await connection.connect();
@@ -1222,6 +1448,18 @@ function createSabhaClient(config) {
1222
1448
  client.configure(config ?? { baseUrl: DEFAULT_BASE_URL });
1223
1449
  return client;
1224
1450
  }
1451
+ function getDevicePeerId(userId) {
1452
+ const key = "sabha_device_peer_id";
1453
+ try {
1454
+ const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem(key) : null;
1455
+ if (existing) return existing;
1456
+ const next = `${userId || "user"}-${Math.random().toString(36).slice(2, 10)}`;
1457
+ if (typeof sessionStorage !== "undefined") sessionStorage.setItem(key, next);
1458
+ return next;
1459
+ } catch {
1460
+ return `${userId || "user"}-${Math.random().toString(36).slice(2, 10)}`;
1461
+ }
1462
+ }
1225
1463
  var SabhaContext = react.createContext(null);
1226
1464
  var idleMedia = {
1227
1465
  audioEnabled: false,
@@ -1284,7 +1522,13 @@ function SabhaProvider({
1284
1522
  }
1285
1523
  setConnectionState(connection.connectionState);
1286
1524
  setMediaState(connection.getMediaState());
1287
- const offState = connection.on("state", setConnectionState);
1525
+ const offState = connection.on("state", (state) => {
1526
+ setConnectionState(state);
1527
+ if (state === "disconnected" || state === "error") {
1528
+ setConnection(null);
1529
+ setMediaState(idleMedia);
1530
+ }
1531
+ });
1288
1532
  const offMedia = connection.on("mediaState", setMediaState);
1289
1533
  return () => {
1290
1534
  offState();
@@ -1378,10 +1622,10 @@ function SabhaProvider({
1378
1622
  [client, connection]
1379
1623
  );
1380
1624
  const connect = react.useCallback(
1381
- async (meetingId) => {
1625
+ async (meetingId, options) => {
1382
1626
  setError(null);
1383
1627
  try {
1384
- const next = await client.connect(meetingId);
1628
+ const next = await client.connect(meetingId, options);
1385
1629
  setConnection(next);
1386
1630
  return next;
1387
1631
  } catch (err) {