fix state pollution and other bugs, and random other stuff

This commit is contained in:
Zoe
2025-09-06 00:23:37 -05:00
parent 68bb6f1d2c
commit f78a156f34
14 changed files with 442 additions and 294 deletions

30
ORGANIZATION.md Normal file
View File

@@ -0,0 +1,30 @@
This document lists the current state of files in this repository. This will serve as a tool for me to reorganize my code and make it easier to find things.
## Directories
### /src
This is the SvelteKit project.
- **lib/**:
- **webrtc.ts**: Holds the WebRTCPeer class, which is used to handle WebRTC connections. It is the place where encryption and decryption is handled.
- **shared/**:
- **keyConfig.ts**: Holds the configuration for the RSA key pair used for wrapping the unique AES-GCM key for each
message, literally nothing else.
- **stores/**:
- **messageStore.ts**: Holds the messages that are sent between the client and the peer.
- **roomStore.ts**: Holds the room information, such as the room ID, the number of participants, and the connection state.
- **websocketStore.ts**: Holds the WebSocket connection.
- **types/**:
- **message.ts**: Defines the types of application messages that are sent between the client and the peer via WebRTC
post initialization.
- **webrtc.ts**: Defines the WebRTCPeerCallbacks, the WebRTCPacketType, the structure of the WebRTCPacket (even
though all WebRTC packets are binary data), and the structure of the KeyStore.
- **websocket.ts**: Defines the WebSocketMessageType, and the types for each message along with the union.
- **utils/**:
- **webrtcUtil.ts**: This file feels like a hodgepodge of random shit. Its responsible for handling application messages that come from the
data channel, as well as handling the websocket signaling and room notifications. It need to be usable by both peers.
### /server
This is the server that handles the webrtc signaling.

View File

@@ -1,22 +1,61 @@
import { WebSocketServer } from "ws";
import type { WebSocket } from "ws";
import { SocketMessageType, type SocketMessage } from "../src/types/websocket";
import { Socket, WebSocketMessageType, type WebSocketMessage } from "../src/types/websocket";
import { LiveMap } from '../src/utils/liveMap.ts';
// TODO: remove stale rooms somehow
const rooms = new Map<string, WebSocket[]>();
export class ServerRoom {
private clients: Socket[] = [];
async function createRoom(socket: WebSocket): Promise<string> {
constructor(clients?: Socket[]) {
if (clients) {
this.clients = clients;
}
}
notifyAll(message: WebSocketMessage) {
this.clients.forEach(client => {
client.send(message);
});
}
get length(): number {
return this.clients.length;
}
push(client: Socket) {
this.clients.push(client);
}
set(clients: Socket[]) {
this.clients = clients;
}
filter(callback: (client: Socket) => boolean): Socket[] {
return this.clients.filter(callback);
}
forEachClient(callback: (client: Socket) => void) {
this.clients.forEach(callback);
}
}
const rooms = new LiveMap<string, ServerRoom>();
async function createRoom(socket: Socket): Promise<string> {
let roomId = Math.random().toString(36).substring(2, 10);
rooms.set(roomId, []);
let room = rooms.set(roomId, new ServerRoom());
socket.send(JSON.stringify({ type: SocketMessageType.ROOM_CREATED, data: roomId }));
socket.send({ type: WebSocketMessageType.ROOM_CREATED, data: room.key });
await joinRoom(roomId, socket);
try {
await joinRoom(room.key, socket);
} catch (e: any) {
throw e;
}
return roomId;
}
async function joinRoom(roomId: string, socket: WebSocket) {
async function joinRoom(roomId: string, socket: Socket): Promise<ServerRoom> {
let room = rooms.get(roomId);
console.log(room?.length);
@@ -26,22 +65,21 @@ async function joinRoom(roomId: string, socket: WebSocket) {
}
if (room.length == 2) {
socket.send(JSON.stringify({ type: SocketMessageType.ERROR, data: 'Room is full' }));
return;
throw new Error("Room is full");
}
// notify all clients in the room of the new client, except the client itself
room.forEach(client => {
client.send(JSON.stringify({ type: SocketMessageType.JOIN_ROOM, data: roomId }));
});
room.notifyAll({ type: WebSocketMessageType.JOIN_ROOM, roomId });
room.push(socket);
socket.addEventListener('close', (ev) => {
room = rooms.get(roomId)
if (!room) {
return;
throw new Error("Room not found");
}
room.notifyAll({ type: WebSocketMessageType.ROOM_LEFT, roomId });
// for some reason, when you filter the array when the length is 1 it stays at 1, but we *know* that if its 1
// then when this client disconnects, the room should be deleted since the room is empty
if (room.length === 1) {
@@ -52,33 +90,33 @@ async function joinRoom(roomId: string, socket: WebSocket) {
deleteRoom(roomId);
}
}, 5000)
deleteRoom(roomId);
return;
}
rooms.set(roomId, room.filter(client => client !== ev.target));
room.set(room.filter(client => client.ws !== ev.target));
});
// TODO: consider letting rooms get larger than 2 clients
if (room.length == 2) {
room.forEach(async client => {
// announce the room is ready, and tell each peer if they are the initiator
client.send(JSON.stringify({ type: SocketMessageType.ROOM_READY, data: { isInitiator: client !== socket } }));
});
room.forEachClient(client => client.send({ type: WebSocketMessageType.ROOM_READY, data: { isInitiator: client !== socket } }));
}
console.log("Room created:", roomId, room.length);
return room;
}
function deleteRoom(roomId: string) {
rooms.delete(roomId);
}
export function confgiureWebsocketServer(ws: WebSocketServer) {
ws.on('connection', socket => {
export function confgiureWebsocketServer(wss: WebSocketServer) {
wss.on('connection', ws => {
let socket = new Socket(ws);
// Handle messages from the client
socket.on('message', async event => {
let message: SocketMessage | undefined = undefined;
ws.on('message', async event => {
let message: WebSocketMessage | undefined = undefined;
if (event instanceof Buffer) { // Assuming JSON is sent as a string
try {
@@ -91,50 +129,57 @@ export function confgiureWebsocketServer(ws: WebSocketServer) {
if (message === undefined) {
console.log("Received non-JSON message:", event);
// If the message is not JSON, send an error message
socket.send(JSON.stringify({ type: SocketMessageType.ERROR, data: 'Invalid message' }));
socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid message' });
return;
}
let room: ServerRoom | undefined = undefined;
switch (message.type) {
case SocketMessageType.CREATE_ROOM:
case WebSocketMessageType.CREATE_ROOM:
// else, create a new room
await createRoom(socket);
try {
await createRoom(socket);
} catch (e: any) {
socket.send({ type: WebSocketMessageType.ERROR, data: e.message });
throw e;
}
break;
case SocketMessageType.JOIN_ROOM:
case WebSocketMessageType.JOIN_ROOM:
// if join message has a roomId, join the room
if (!message.roomId) {
socket.send(JSON.stringify({ type: SocketMessageType.ERROR, data: 'Invalid message' }));
socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid message' });
return;
}
// if the user tries to join a room that doesnt exist, send an error message
if (rooms.get(message.roomId) == undefined) {
socket.send(JSON.stringify({ type: SocketMessageType.ERROR, data: 'Invalid roomId' }));
socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid roomId' });
return;
}
await joinRoom(message.roomId, socket);
room = await joinRoom(message.roomId, socket);
// the client is now in the room and the peer knows about it
socket.send(JSON.stringify({ type: SocketMessageType.ROOM_JOINED, roomId: message.roomId }));
socket.send({ type: WebSocketMessageType.ROOM_JOINED, roomId: message.roomId, participants: room.length });
break;
case SocketMessageType.OFFER:
case SocketMessageType.ANSWER:
case SocketMessageType.ICE_CANDIDATE:
case WebSocketMessageType.WEBRTC_OFFER:
case WebSocketMessageType.WERTC_ANSWER:
case WebSocketMessageType.WEBRTC_ICE_CANDIDATE:
// relay these messages to the other peers in the room
const room = rooms.get(message.data.roomId);
room = rooms.get(message.data.roomId);
if (room) {
room.forEach(client => {
room.forEachClient(client => {
if (client !== socket) {
client.send(JSON.stringify(message));
client.send(message);
}
});
}
break;
default:
console.warn(`Unknown message type: ${message.type}`);
socket.send(JSON.stringify({ type: SocketMessageType.ERROR, data: 'Unknown message type' }));
socket.send({ type: WebSocketMessageType.ERROR, data: 'Unknown message type' });
break;
}
});

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { writable, type Writable } from "svelte/store";
import { room } from "../stores/roomStore";
import { webSocketConnected } from "../stores/websocketStore";
import { derived, writable, type Writable } from "svelte/store";
// import { room } from "../stores/roomStore";
import { webSocketConnected, ws } from "../stores/websocketStore";
import {
isRTCConnected,
dataChannelReady,
@@ -10,13 +10,26 @@
} from "../utils/webrtcUtil";
import { messages } from "../stores/messageStore";
import { WebRTCPacketType } from "../types/webrtc";
import { ConnectionState } from "../types/websocket";
import { ConnectionState, type Room } from "../types/websocket";
import { MessageType } from "../types/message";
import { fade } from "svelte/transition";
let inputMessage: Writable<string> = writable("");
let inputFile = writable(null);
let inputFileElement: HTMLInputElement;
let inputFileElement: HTMLInputElement | null = $state(null);
let initialConnectionComplete = derived(
[isRTCConnected, dataChannelReady, keyExchangeDone],
(values: Array<boolean>) => values.every((value) => value),
);
const { room }: { room: Writable<Room> } = $props();
room.subscribe((newRoom) => {
console.log("Room changed:", newRoom);
if (newRoom.id !== $room?.id) {
messages.set([]);
}
});
function sendMessage() {
if (!$peer) {
@@ -68,21 +81,29 @@
});
function pickFile() {
if (!inputFileElement) return;
inputFileElement.click();
}
</script>
<p>{$room?.id} - {$room?.connectionState} - {$webSocketConnected}</p>
<p>
{$room?.id}
({$room?.participants}) - {$room?.connectionState} - {$webSocketConnected}
- Initial connection {$initialConnectionComplete
? "complete"
: "incomplete"}
</p>
<!-- If we are in a room, connected to the websocket server, and the have been informed that we are connected to the room -->
{#if $room !== null && $webSocketConnected === true && $room.connectionState === ConnectionState.CONNECTED}
<!-- If we are in a room, connected to the websocket server, and have been informed that we are connected to the room -->
{#if ($room !== null && $webSocketConnected === true && $room.connectionState === ConnectionState.CONNECTED) || $room.connectionState === ConnectionState.RECONNECTING}
<div
class="flex flex-col sm:max-w-4/5 lg:max-w-3/5 min-h-[calc(5/12_*_100vh)]"
>
<div
class="flex-grow flex flex-col overflow-y-auto mb-4 p-2 bg-gray-800 rounded break-all relative"
>
{#if !$isRTCConnected || !$dataChannelReady || !$keyExchangeDone || !$canCloseLoadingOverlay}
{#if !$initialConnectionComplete || $room.connectionState === ConnectionState.RECONNECTING || $room.participants !== 2 || !$canCloseLoadingOverlay}
<div
transition:fade={{ duration: 300 }}
class="absolute top-0 left-0 bottom-0 right-0 flex justify-center items-center flex-col bg-black/55 backdrop-blur-md"
@@ -93,6 +114,15 @@
<p>Establishing data channel...</p>
{:else if !$keyExchangeDone}
<p>Establishing a secure connection with the peer...</p>
{:else if $room.connectionState === ConnectionState.RECONNECTING}
<p>
Disconnect from peer, attempting to reconnecting...
</p>
{:else if $room.participants !== 2}
<p>
Peer has disconnected, waiting for other peer to
reconnect...
</p>
{:else}
<p>
Successfully established a secure connection to
@@ -100,7 +130,7 @@
</p>
{/if}
<div class="mt-2">
{#if !$keyExchangeDone}
{#if !$keyExchangeDone || $room.participants !== 2 || $room.connectionState === ConnectionState.RECONNECTING}
<!-- loading spinner -->
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
@@ -171,19 +201,21 @@
<input
type="text"
bind:value={$inputMessage}
on:keyup={(e) => e.key === "Enter" && sendMessage()}
onkeyup={(e) => e.key === "Enter" && sendMessage()}
disabled={!$isRTCConnected ||
!$dataChannelReady ||
!$keyExchangeDone}
!$keyExchangeDone ||
$room.connectionState === ConnectionState.RECONNECTING}
placeholder="Type your message..."
class="flex-grow p-2 rounded bg-gray-700 border border-gray-600 text-gray-100 placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<button
on:click={pickFile}
onclick={pickFile}
disabled={!$isRTCConnected ||
!$dataChannelReady ||
!$keyExchangeDone}
!$keyExchangeDone ||
$room.connectionState === ConnectionState.RECONNECTING}
aria-label="Pick file"
class="px-4 py-2 bg-blue-600 not-disabled:hover:bg-blue-700 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
@@ -203,10 +235,11 @@
>
</button>
<button
on:click={sendMessage}
onclick={sendMessage}
disabled={!$isRTCConnected ||
!$dataChannelReady ||
!$keyExchangeDone}
!$keyExchangeDone ||
$room.connectionState === ConnectionState.RECONNECTING}
class="px-4 py-2 bg-blue-600 not-disabled:hover:bg-blue-700 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
@@ -214,3 +247,9 @@
</div>
</div>
{/if}
<button
onclick={() => {
$ws.close();
}}>Simulate disconnect</button
>

View File

@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -1,7 +1,9 @@
import { get } from 'svelte/store';
import { WebSocketMessageType, ws } from '../stores/websocketStore';
import { ws } from '../stores/websocketStore';
import { WebSocketMessageType } from '../types/websocket';
import { WebRTCPacketType, type KeyStore, type WebRTCPeerCallbacks } from '../types/webrtc';
import { clientKeyConfig } from '../shared/keyConfig';
import { browser } from '$app/environment';
export class WebRTCPeer {
private peer: RTCPeerConnection | null = null;
@@ -38,6 +40,8 @@ export class WebRTCPeer {
}
public async initialize() {
if (!browser) throw new Error("Cannot initialize WebRTCPeer in non-browser environment");
// dont initialize twice
if (this.peer) return;
@@ -115,14 +119,9 @@ export class WebRTCPeer {
console.log("Received key exchange", data.buffer);
const textDecoder = new TextDecoder();
const jsonKey = JSON.parse(textDecoder.decode(data));
console.log("Received key exchange", jsonKey);
this.keys.peersPublicKey = await window.crypto.subtle.importKey(
"jwk",
jsonKey,
"spki",
data.buffer,
clientKeyConfig,
true,
["wrapKey"],
@@ -276,14 +275,12 @@ export class WebRTCPeer {
console.log("exporting key", this.keys.localKeys.publicKey);
const exported = await window.crypto.subtle.exportKey("jwk", this.keys.localKeys.publicKey);
const exported = await window.crypto.subtle.exportKey("spki", this.keys.localKeys.publicKey);
// convert exported key to a string then pack that sting into an array buffer
const exportedKeyBuffer = new TextEncoder().encode(JSON.stringify(exported));
console.log("exported key buffer", exported);
console.log("exported key buffer", exportedKeyBuffer);
this.send(exportedKeyBuffer.buffer, WebRTCPacketType.KEY_EXCHANGE);
this.send(exported, WebRTCPacketType.KEY_EXCHANGE);
}
private async encrypt(data: Uint8Array<ArrayBuffer>, key: CryptoKey, iv: Uint8Array<ArrayBuffer>): Promise<ArrayBuffer> {

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
import "../app.css";
import favicon from "$lib/assets/favicon.svg";
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="icon" href={favicon} />
</svelte:head>
{@render children?.()}

View File

@@ -1,9 +1,6 @@
<script lang="ts">
import {
ws,
webSocketConnected,
WebSocketMessageType,
} from "../stores/websocketStore";
import { ws, webSocketConnected } from "../stores/websocketStore";
import { WebSocketMessageType } from "../types/websocket";
import { room } from "../stores/roomStore";
import { browser } from "$app/environment";
import { peer, handleMessage } from "../utils/webrtcUtil";
@@ -12,10 +9,6 @@
import { ConnectionState } from "../types/websocket";
onMount(async () => {
room.update((room) => ({
...room,
connectionState: ConnectionState.CONNECTING,
}));
$ws.addEventListener("message", handleMessage);
});
@@ -38,7 +31,14 @@
{#if $webSocketConnected}
<button
on:click={() => {
onclick={() => {
// if we are in a room already, leave it
if ($room.id) {
$ws.send({
type: WebSocketMessageType.LEAVE_ROOM,
roomId: $room.id,
});
}
$ws.send({ type: WebSocketMessageType.CREATE_ROOM }); // send a message when the button is clicked
}}>Create Room</button
>
@@ -52,5 +52,5 @@
<a href={`${location.origin}/${$room}`}>{location.origin}/{$room.id}</a>
{/if}
<RtcMessage />
<RtcMessage {room} />
</div>

View File

@@ -1,39 +1,36 @@
<script lang="ts">
import { page } from "$app/state";
import { onDestroy, onMount } from "svelte";
import { room } from "../../stores/roomStore";
import { error, handleMessage, peer } from "../../utils/webrtcUtil";
import {
ws,
webSocketConnected,
WebSocketMessageType,
} from "../../stores/websocketStore";
import { ws, webSocketConnected } from "../../stores/websocketStore";
import { WebSocketMessageType } from "../../types/websocket";
import RtcMessage from "../../components/RTCMessage.svelte";
import { ConnectionState } from "../../types/websocket";
const roomId = page.params.roomId;
if (roomId === undefined) {
throw new Error("Room ID not provided");
}
// subscribe to the websocket store
room.update((room) => ({ ...room, id: roomId }));
export let data: { roomId: string };
const { roomId } = data;
onMount(async () => {
room.update((room) => ({ ...room, id: roomId }));
$ws.addEventListener("message", handleMessage);
webSocketConnected.subscribe((value) => {
if (value) {
$ws.send({ type: WebSocketMessageType.JOIN_ROOM, roomId });
room.update((room) => ({
...room,
connectionState: ConnectionState.CONNECTING,
}));
if ($room.id === null) {
throw new Error("Room ID not set");
}
$ws.send({
type: WebSocketMessageType.JOIN_ROOM,
roomId: $room.id,
});
}
});
// $ws.onopen = () => {
// room.update((room) => ({
// ...room,
// connectionState: ConnectionState.CONNECTING,
// }));
// $ws.send({ type: WebSocketMessageType.JOIN_ROOM, roomId });
// };
});
onDestroy(() => {
@@ -53,9 +50,9 @@
<div class="p-4">
{#if $error}
<p>Whoops! That room doesn't exist.</p>
{:else if !$webSocketConnected || $room.connectionState === ConnectionState.CONNECTING}
{:else if $room.connectionState !== ConnectionState.CONNECTED && $room.connectionState !== ConnectionState.RECONNECTING}
<p>Connecting to server...</p>
{:else}
<RtcMessage />
<RtcMessage {room} />
{/if}
</div>

View File

@@ -0,0 +1,17 @@
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = ({ params }) => {
const roomId = params.roomId;
if (!roomId) {
// SvelteKit's way of handling errors in load functions
throw error(404, 'Room ID not provided');
}
// This return value is SAFELY passed to your page component
// It is NOT stored in a global variable on the server.
return {
roomId: roomId
};
};

View File

@@ -1,13 +1,16 @@
import { writable, type Writable } from 'svelte/store';
import { ConnectionState } from '../types/websocket';
import { browser } from '$app/environment';
export interface Room {
id: string | null;
participants: number;
connectionState: ConnectionState;
}
export const room: Writable<Room> = writable({
id: null,
participants: 0,
connectionState: ConnectionState.DISCONNECTED,
key: null,
});

View File

@@ -1,120 +1,7 @@
import { writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import { browser } from '$app/environment';
export enum WebSocketMessageType {
// room messages
CREATE_ROOM = "create",
JOIN_ROOM = "join",
// response messages
ROOM_CREATED = "created",
ROOM_JOINED = "joined",
ROOM_READY = "ready",
// webrtc messages
WEBRTC_OFFER = "offer",
WERTC_ANSWER = "answer",
WEBRTC_ICE_CANDIDATE = "ice-candidate",
ERROR = "error",
}
export type WebSocketMessage =
| CreateRoomMessage
| JoinRoomMessage
| RoomCreatedMessage
| RoomJoinedMessage
| RoomReadyMessage
| OfferMessage
| AnswerMessage
| IceCandidateMessage
| ErrorMessage;
interface ErrorMessage {
type: WebSocketMessageType.ERROR;
data: string;
}
interface CreateRoomMessage {
type: WebSocketMessageType.CREATE_ROOM;
}
interface JoinRoomMessage {
type: WebSocketMessageType.JOIN_ROOM;
roomId: string;
}
interface RoomCreatedMessage {
type: WebSocketMessageType.ROOM_CREATED;
data: string;
}
interface RoomJoinedMessage {
type: WebSocketMessageType.ROOM_JOINED;
roomId: string;
}
interface RoomReadyMessage {
type: WebSocketMessageType.ROOM_READY;
data: {
isInitiator: boolean;
roomKey: {
key: JsonWebKey;
};
};
}
interface OfferMessage {
type: WebSocketMessageType.WEBRTC_OFFER;
data: {
roomId: string;
sdp: RTCSessionDescriptionInit;
};
}
interface AnswerMessage {
type: WebSocketMessageType.WERTC_ANSWER;
data: {
roomId: string;
sdp: RTCSessionDescriptionInit;
};
}
interface IceCandidateMessage {
type: WebSocketMessageType.WEBRTC_ICE_CANDIDATE;
data: {
roomId: string;
candidate: RTCIceCandidateInit;
};
}
export class Socket {
private ws: WebSocket;
public addEventListener: typeof WebSocket.prototype.addEventListener;
public removeEventListener: typeof WebSocket.prototype.removeEventListener;
public dispatchEvent: typeof WebSocket.prototype.dispatchEvent;
public close: typeof WebSocket.prototype.close;
constructor(public url: string, public protocols?: string | string[] | undefined) {
this.ws = new WebSocket(url, protocols);
this.ws.addEventListener("open", () => {
console.log("WebSocket opened");
});
this.addEventListener = this.ws.addEventListener.bind(this.ws);
this.removeEventListener = this.ws.removeEventListener.bind(this.ws);
this.dispatchEvent = this.ws.dispatchEvent.bind(this.ws);
this.close = this.ws.close.bind(this.ws);
}
public send(message: WebSocketMessage) {
console.log("Sending message:", message);
this.ws.send(JSON.stringify(message));
}
}
import { room } from './roomStore';
import { ConnectionState, Socket, WebSocketMessageType } from '../types/websocket';
let socket: Socket | null = null;
export const webSocketConnected = writable(false);
@@ -129,7 +16,7 @@ function createSocket(): Socket {
}
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new Socket(`${protocol}//${location.host}/`);
socket = new Socket(new WebSocket(`${protocol}//${location.host}/`));
socket.addEventListener('open', () => {
webSocketConnected.set(true);
@@ -137,12 +24,27 @@ function createSocket(): Socket {
});
socket.addEventListener('close', () => {
// TODO: massively rework the reconnection logic, currently it only works if one client disconnects, if the
// TODO: other client disconnects after the other client has diconnected at least once, everything explodes
if (get(webSocketConnected) && get(room)?.connectionState === ConnectionState.CONNECTED) {
room.update((room) => ({ ...room, connectionState: ConnectionState.RECONNECTING }));
setTimeout(() => {
ws.set(createSocket());
// attempt to rejoin the room if we were previously connected
get(ws).addEventListener('open', () => {
let oldRoomId = get(room)?.id;
if (oldRoomId) {
get(ws).send({ type: WebSocketMessageType.JOIN_ROOM, roomId: oldRoomId });
room.update((room) => ({ ...room, connectionState: ConnectionState.CONNECTED }));
}
});
}, 1000);
}
webSocketConnected.set(false);
socket = null;
console.log('Disconnected from websocket server, reconnecting...');
setTimeout(() => {
ws.set(createSocket());
}, 1000);
});
return socket;

View File

@@ -1,96 +1,145 @@
export enum ConnectionState {
CONNECTING,
RECONNECTING,
CONNECTED,
DISCONNECTED,
}
export enum SocketMessageType {
// requests
CREATE_ROOM = 'create',
JOIN_ROOM = 'join',
// responses
ROOM_CREATED = 'created',
ROOM_JOINED = 'joined',
ROOM_READY = 'ready',
// webrtc
ICE_CANDIDATE = 'ice-candidate',
OFFER = 'offer',
ANSWER = 'answer',
ERROR = 'error',
export interface Room {
id: string | null;
participants: number;
connectionState: ConnectionState;
}
type SocketMessageBase = {
type: SocketMessageType;
};
export enum WebSocketMessageType {
// room messages
CREATE_ROOM = "create",
JOIN_ROOM = "join",
LEAVE_ROOM = "leave",
export interface SocketMessageCreateRoom extends SocketMessageBase {
type: SocketMessageType.CREATE_ROOM;
// response messages
ROOM_CREATED = "created",
ROOM_JOINED = "joined",
ROOM_LEFT = "left",
ROOM_READY = "ready",
// webrtc messages
WEBRTC_OFFER = "offer",
WERTC_ANSWER = "answer",
WEBRTC_ICE_CANDIDATE = "ice-candidate",
ERROR = "error",
}
export interface SocketMessageJoinRoom extends SocketMessageBase {
type: SocketMessageType.JOIN_ROOM;
export type WebSocketMessage =
| CreateRoomMessage
| JoinRoomMessage
| LeaveRoomMessage
| RoomCreatedMessage
| RoomJoinedMessage
| RoomLeftMessage
| RoomReadyMessage
| OfferMessage
| AnswerMessage
| IceCandidateMessage
| ErrorMessage;
interface ErrorMessage {
type: WebSocketMessageType.ERROR;
data: string;
}
interface CreateRoomMessage {
type: WebSocketMessageType.CREATE_ROOM;
}
interface JoinRoomMessage {
type: WebSocketMessageType.JOIN_ROOM;
roomId: string;
}
export interface SocketMessageRoomCreated extends SocketMessageBase {
type: SocketMessageType.ROOM_CREATED;
data: {
roomId: string;
};
}
export interface SocketMessageRoomJoined extends SocketMessageBase {
type: SocketMessageType.ROOM_JOINED;
interface LeaveRoomMessage {
type: WebSocketMessageType.LEAVE_ROOM;
roomId: string;
}
export interface SocketMessageRoomReady extends SocketMessageBase {
type: SocketMessageType.ROOM_READY;
interface RoomCreatedMessage {
type: WebSocketMessageType.ROOM_CREATED;
data: string;
}
interface RoomJoinedMessage {
type: WebSocketMessageType.ROOM_JOINED;
roomId: string;
participants: number;
}
interface RoomLeftMessage {
type: WebSocketMessageType.ROOM_LEFT;
roomId: string;
}
interface RoomReadyMessage {
type: WebSocketMessageType.ROOM_READY;
data: {
roomId: string;
isInitiator: boolean;
};
}
export interface SocketMessageIceCandidate extends SocketMessageBase {
type: SocketMessageType.ICE_CANDIDATE;
interface OfferMessage {
type: WebSocketMessageType.WEBRTC_OFFER;
data: {
roomId: string;
candidate: RTCIceCandidate;
sdp: RTCSessionDescriptionInit;
};
}
export interface SocketMessageOffer extends SocketMessageBase {
type: SocketMessageType.OFFER;
interface AnswerMessage {
type: WebSocketMessageType.WERTC_ANSWER;
data: {
roomId: string;
sdp: RTCSessionDescription;
sdp: RTCSessionDescriptionInit;
};
}
export interface SocketMessageAnswer extends SocketMessageBase {
type: SocketMessageType.ANSWER;
interface IceCandidateMessage {
type: WebSocketMessageType.WEBRTC_ICE_CANDIDATE;
data: {
roomId: string;
sdp: RTCSessionDescription;
candidate: RTCIceCandidateInit;
};
}
export interface SocketMessageError extends SocketMessageBase {
type: SocketMessageType.ERROR;
data: string;
export interface SocketCallbacks {
onOpen: () => void;
onClose: () => void;
}
export type SocketMessage =
| SocketMessageCreateRoom
| SocketMessageJoinRoom
| SocketMessageRoomCreated
| SocketMessageRoomJoined
| SocketMessageRoomReady
| SocketMessageIceCandidate
| SocketMessageOffer
| SocketMessageAnswer
| SocketMessageError;
export class Socket {
public ws: WebSocket;
public addEventListener: typeof WebSocket.prototype.addEventListener;
public removeEventListener: typeof WebSocket.prototype.removeEventListener;
public dispatchEvent: typeof WebSocket.prototype.dispatchEvent;
public close: typeof WebSocket.prototype.close;
constructor(webSocket: WebSocket) {
this.ws = webSocket;
this.ws.addEventListener("open", () => {
console.log("WebSocket opened");
});
this.addEventListener = this.ws.addEventListener.bind(this.ws);
this.removeEventListener = this.ws.removeEventListener.bind(this.ws);
this.dispatchEvent = this.ws.dispatchEvent.bind(this.ws);
this.close = this.ws.close.bind(this.ws);
}
public send(message: WebSocketMessage) {
console.log("Sending message:", message);
this.ws.send(JSON.stringify(message));
}
}

65
src/utils/liveMap.ts Normal file
View File

@@ -0,0 +1,65 @@
type LiveMapEntry<K, V> = V & { key: K; value: V; };
export class LiveMap<K, V extends Object> {
_map = new Map();
set(key: K, value: V): LiveMapEntry<K, V> {
if (this._map.has(key)) {
throw new Error(`Key ${key} already exists in the map`);
}
this._map.set(key, value);
// Create a wrapper object that holds both key and value for easy access, with mutation handling
let currentValueInMap: V = value;
const mapRef = this._map;
// use a dummy target object to proxy the value
const obj = new Proxy({}, {
get(target: object, prop: string | symbol, receiver: any): any {
if (prop === "key") {
return key;
}
if (prop === "value") {
return value;
}
return Reflect.get(currentValueInMap as object, prop, receiver);
},
set(target, prop, newValue) {
if (prop === "value") {
value = newValue;
mapRef.set(key, value);
return true;
}
return Reflect.set(target, prop, newValue);
},
deleteProperty(target: object, prop: string | symbol): boolean {
if (prop === "key" || prop === "value") {
return false; // Prevent deleting special properties
}
return Reflect.deleteProperty(currentValueInMap as object, prop);
},
has(target: object, prop: string | symbol): boolean {
if (prop === "key" || prop === "value") {
return true;
}
return Reflect.has(currentValueInMap as object, prop);
},
ownKeys(target: object): Array<string | symbol> {
const keys = Reflect.ownKeys(currentValueInMap as object);
// Ensure 'key' and 'value' are always present when iterating over properties
if (!keys.includes("key")) keys.push("key");
if (!keys.includes("value")) keys.push("value");
return keys;
}
}) as LiveMapEntry<K, V>;
return obj;
}
get(key: K): V | undefined {
return this._map.get(key);
}
delete(key: K): boolean {
return this._map.delete(key);
}
}

View File

@@ -2,10 +2,10 @@ import { writable, get, type Writable } from "svelte/store";
import { WebRTCPeer } from "$lib/webrtc";
import { WebRTCPacketType } from "../types/webrtc";
import { room } from "../stores/roomStore";
import { ConnectionState } from "../types/websocket";
import { ConnectionState, type Room } from "../types/websocket";
import { messages } from "../stores/messageStore";
import { MessageType, type Message } from "../types/message";
import { WebSocketMessageType, type WebSocketMessage } from "../stores/websocketStore";
import { WebSocketMessageType, type WebSocketMessage } from "../types/websocket";
export const error: Writable<string | null> = writable(null);
export let peer: Writable<WebRTCPeer | null> = writable(null);
@@ -73,15 +73,23 @@ export async function handleMessage(event: MessageEvent) {
switch (message.type) {
case WebSocketMessageType.ROOM_CREATED:
console.log("Room created:", message.data);
room.update((room) => ({ ...room, id: message.data, connectionState: ConnectionState.CONNECTED }));
room.update((room) => ({ ...room, id: message.data, connectionState: ConnectionState.CONNECTED, participants: 1 }));
return;
case WebSocketMessageType.JOIN_ROOM:
console.log("new client joined room");
room.update((room) => ({ ...room, participants: room.participants + 1 }));
return;
case WebSocketMessageType.ROOM_JOINED:
room.update((room) => ({ ...room, connectionState: ConnectionState.CONNECTED }));
// TODO: if a client disconnects, somehow prove the identity of the client that left if they return. Perhaps
// TODO: use a key derived from client's public key so that the room can only be used by clients that initiated
// TODO: the connection
room.update((room) => ({ ...room, connectionState: ConnectionState.CONNECTED, participants: message.participants }));
console.log("Joined room");
return;
case WebSocketMessageType.ROOM_LEFT:
room.update((room) => ({ ...room, participants: room.participants - 1 }));
console.log("Participant left room");
return;
case WebSocketMessageType.ERROR:
console.error("Error:", message.data);
error.set(message.data);
@@ -100,9 +108,6 @@ export async function handleMessage(event: MessageEvent) {
callbacks,
));
await get(peer)?.initialize();
if (message.data.isInitiator) {
await get(peer)?.createOffer();
}
return;
}