proof of work, bug fixes, reorg, more

This commit is contained in:
Zoe
2025-09-15 22:24:43 -05:00
parent de96b33a41
commit cad5d6d98e
21 changed files with 412 additions and 88 deletions

197
src/lib/buffer.ts Normal file
View File

@@ -0,0 +1,197 @@
// nodejs like buffer class for browser
export class WebBuffer {
private data: Uint8Array<ArrayBuffer>;
// the number of bytes read from the buffer, this allows for you to read the buffer without having to specify the offset every time
private count = 0;
private dataView: DataView;
constructor(data: ArrayBuffer) {
this.data = new Uint8Array(data);
this.dataView = new DataView(data);
return new Proxy(this, {
get(target, prop, receiver) {
// Check if the property is a string that represents a valid number (array index)
if (typeof prop === 'string' && /^\d+$/.test(prop)) {
const index = parseInt(prop, 10);
// Delegate array-like access to the underlying Uint8Array
return target.data[index];
}
// For all other properties (methods like slice, getters like length, etc.),
// use the default property access behavior on the target object.
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
// Check if the property is a string that represents a valid number (array index)
if (typeof prop === 'string' && /^\d+$/.test(prop)) {
const index = parseInt(prop, 10);
// Delegate array-like assignment to the underlying Uint8Array
target.data[index] = value;
return true; // Indicate success
}
// For all other properties, use the default property assignment behavior.
return Reflect.set(target, prop, value, receiver);
}
});
}
[index: number]: number;
get length(): number {
return this.data.length;
}
get buffer(): ArrayBuffer {
return this.data.buffer;
}
slice(start: number, end?: number): WebBuffer {
return new WebBuffer(this.data.slice(start, end).buffer);
}
set(data: number, offset: number) {
this.dataView.setUint8(offset, data);
// this.data.set(data, offset);
}
read(length?: number, offset?: number): Uint8Array {
if (length === undefined) {
length = this.length - this.count;
}
if (offset === undefined) {
offset = this.count;
this.count += length;
}
return this.data.slice(offset, offset + length);
}
write(data: Uint8Array, offset?: number) {
if (offset === undefined) {
offset = this.count;
this.count += data.byteLength;
}
for (let i = 0; i < data.byteLength; i++) {
this.dataView.setUint8(offset + i, data[i]);
}
}
readInt8(offset?: number): number {
if (offset === undefined) {
offset = this.count;
this.count += 1;
}
return this.dataView.getUint8(offset);
}
writeInt8(value: number, offset?: number) {
if (offset === undefined) {
offset = this.count;
this.count += 1;
}
this.dataView.setUint8(offset, value);
}
readInt16LE(offset?: number): number {
if (offset === undefined) {
offset = this.count;
this.count += 2;
}
return this.dataView.getInt16(offset, true);
}
writeInt16LE(value: number, offset?: number) {
if (offset === undefined) {
offset = this.count;
this.count += 2;
}
this.dataView.setInt16(offset, value, true);
}
readInt32LE(offset?: number): number {
if (offset === undefined) {
offset = this.count;
this.count += 4;
}
return this.dataView.getInt32(offset, true);
}
writeInt32LE(value: number, offset?: number) {
if (offset === undefined) {
offset = this.count;
this.count += 4;
}
this.dataView.setInt32(offset, value, true);
}
readBigInt64LE(offset?: number): bigint {
if (offset === undefined) {
offset = this.count;
this.count += 8;
}
return this.dataView.getBigInt64(offset, true);
}
writeBigInt64LE(value: bigint, offset?: number) {
if (offset === undefined) {
offset = this.count;
this.count += 8;
}
this.dataView.setBigInt64(offset, value, true);
}
// if no length is specified, reads until the end of the buffer
readString(length?: number, offset?: number): string {
if (length === undefined) {
length = this.length - this.count;
}
if (offset === undefined) {
offset = this.count;
this.count += length;
}
let textDeccoder = new TextDecoder();
let readTextBuf = this.data.slice(offset, offset + length);
let value = textDeccoder.decode(readTextBuf);
return value;
}
writeString(value: string, offset?: number) {
if (offset === undefined) {
offset = this.count;
this.count += value.length;
}
let textEncoder = new TextEncoder();
let textBuf = textEncoder.encode(value);
this.data.set(textBuf, offset);
}
// lets you peek at the next byte without advancing the read pointer
peek(): number {
return this.data[this.count];
}
[Symbol.iterator]() {
// Return an iterator over the values of the underlying Uint8Array
return this.data.values();
}
// Optional: Add Symbol.toStringTag for better console output
get [Symbol.toStringTag]() {
return 'WebBuffer';
}
}

45
src/lib/challenge.ts Normal file
View File

@@ -0,0 +1,45 @@
import { ws } from "$stores/websocketStore";
import { WebSocketMessageType } from "$types/websocket";
import { solveChallenge } from "./powUtil";
export async function doChallenge(additionalData: string = ""): Promise<{
challenge: string;
nonce: string;
} | null> {
let roomChallenge: string | null = null;
let challengePromise = new Promise<string | null>((resolve) => {
let unsubscribe = ws.handleEvent(
WebSocketMessageType.CHALLENGE,
async (value) => {
unsubscribe();
roomChallenge = value.challenge;
resolve(
await solveChallenge(
roomChallenge,
value.difficulty,
additionalData,
),
);
},
);
});
ws.send({
type: WebSocketMessageType.REQUEST_CHALLENGE,
});
let challengeNonce = await challengePromise;
if (!challengeNonce) {
throw new Error("Could not solve challenge within max iterations");
}
if (!roomChallenge) {
throw new Error("No room challenge");
}
return {
challenge: roomChallenge,
nonce: challengeNonce,
};
}

65
src/lib/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);
}
}

25
src/lib/powUtil.ts Normal file
View File

@@ -0,0 +1,25 @@
export async function hashStringSHA256(message: string): Promise<string> {
const textEncoder = new TextEncoder();
const data = textEncoder.encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
}
export async function solveChallenge(challenge: string, difficulty: number, additionalData: string): Promise<string | null> {
let nonce = 0;
let targetPrefix = '0'.repeat(difficulty);
let maxIterations = 1_000_000;
while (nonce < maxIterations) {
let hash = await hashStringSHA256(`${additionalData}${challenge}${nonce}`);
if (hash.startsWith(targetPrefix)) {
return nonce.toString();
}
nonce++;
}
return null;
}

View File

@@ -0,0 +1,332 @@
import { WebSocketServer } from "ws";
import { Socket, WebSocketMessageType, type WebSocketMessage } from "../../types/websocket.ts";
import { LiveMap } from '../liveMap.ts';
import { hashStringSHA256 } from "../powUtil.ts";
const adjectives = ['swift', 'silent', 'hidden', 'clever', 'brave', 'sharp', 'shadow', 'crimson', 'bright', 'quiet', 'loud', 'happy', 'dark', 'evil', 'good', 'intelligent', 'lovely', 'mysterious', 'peaceful', 'powerful', 'pure', 'quiet', 'shiny', 'sleepy', 'strong', 'sweet', 'tall', 'warm', 'gentle', 'kind', 'nice', 'polite', 'rough', 'rude', 'scary', 'shy', 'silly', 'smart', 'strange', 'tough', 'ugly', 'vivid', 'wicked', 'wise', 'young', 'sleepy'];
const nouns = ['fox', 'river', 'stone', 'cipher', 'link', 'comet', 'falcon', 'signal', 'anchor', 'spark', 'stone', 'comet', 'rocket', 'snake', 'snail', 'shark', 'elephant', 'cat', 'dog', 'whale', 'orca', 'cactus', 'flower', 'frog', 'toad', 'apple', 'strawberry', 'raspberry', 'lemon', 'bot', 'gopher', 'dinosaur', 'racoon', 'penguin', 'chameleon', 'atom', 'particle', 'witch', 'wizard', 'warlock', 'deer']
const errors = {
MALFORMED_MESSAGE: "Invalid message",
INVALID_CHALLENGE: "Invalid challenge",
MISSING_DATA: "One or more required fields are missing",
ROOM_NOT_FOUND: "Room does not exist",
ROOM_FULL: "Room is full",
UNKNOWN_MESSAGE_TYPE: "Unknown message type",
}
export class ServerRoom {
private clients: Socket[] = [];
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);
}
}
function generateRoomName(): string {
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
return `${adj}-${noun}`;
}
const rooms = new LiveMap<string, ServerRoom>();
async function createRoom(socket: Socket, roomName?: string): Promise<string> {
if (!roomName) {
roomName = generateRoomName();
}
const num = Math.floor(Math.random() * 900) + 100;
const roomId = `${roomName}-${num}`;
let room = rooms.set(roomId, new ServerRoom());
socket.send({ type: WebSocketMessageType.ROOM_CREATED, data: room.key });
try {
await joinRoom(room.key, socket, true);
} catch (e: any) {
throw e;
}
return roomId;
}
async function joinRoom(roomId: string, socket: Socket, initial?: boolean): Promise<ServerRoom | undefined> {
let room = rooms.get(roomId);
console.log(room?.length);
// should be unreachable
if (!room) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND });
return undefined;
}
if (room.length == 2) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_FULL });
return undefined;
}
// notify all clients in the room of the new client, except the client itself
room.notifyAll({ type: WebSocketMessageType.JOIN_ROOM, roomId });
room.push(socket);
socket.addEventListener('close', (ev) => {
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) {
// give a 60 second grace period before deleting the room
setTimeout(() => {
if (rooms.get(roomId)?.length === 1) {
console.log("Room is empty, deleting");
deleteRoom(roomId);
}
}, 60000)
return;
}
room.set(room.filter(client => client.ws !== ev.target));
});
if (!initial) {
socket.send({ type: WebSocketMessageType.ROOM_JOINED, roomId: roomId, participants: room.length });
}
// TODO: consider letting rooms get larger than 2 clients
if (room.length == 2) {
room.forEachClient(client => client.send({ type: WebSocketMessageType.ROOM_READY, data: { isInitiator: client !== socket } }));
}
console.log("Room created:", roomId, room.length);
return room;
}
// How many leading zeros are required to be considered solved
// In my testing, 2 seems to be too easy, and 4 seems to be too hard, so I'm going with 3
const CHALLENGE_DIFFICULTY = 3;
// challenges that have yet to be attached to a challenged request
const outstandingChallenges = new Map<string, NodeJS.Timeout>();
function generateChallenge(): string {
let challenge = Array.from(crypto.getRandomValues(new Uint8Array(32))).map(b => b.toString(16).padStart(2, '0')).join('');
// provide 90 seconds to solve the challenge
outstandingChallenges.set(challenge, setTimeout(() => {
console.log("Challenge timed out:", challenge);
outstandingChallenges.delete(challenge);
}, 90000));
return challenge;
}
async function validateChallenge(challenge: string, nonce: string, additionalData: string = ""): Promise<boolean> {
if (!outstandingChallenges.has(challenge)) {
return false;
}
let hash = await hashStringSHA256(`${additionalData}${challenge}${nonce}`);
let result = hash.startsWith('0'.repeat(CHALLENGE_DIFFICULTY));
if (result) {
console.log("Challenge solved:", challenge);
clearTimeout(outstandingChallenges.get(challenge)!);
outstandingChallenges.delete(challenge);
}
return result;
}
function leaveRoom(roomId: string, socket: Socket): ServerRoom | undefined {
let room = rooms.get(roomId);
console.log(room?.length);
// should be unreachable
if (!room) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND });
return undefined;
}
if (room.length == 1) {
// give a 5 second grace period before deleting the room
setTimeout(() => {
if (rooms.get(roomId)?.length === 1) {
console.log("Room is empty, deleting");
deleteRoom(roomId);
}
}, 5000)
return;
}
room.set(room.filter(client => client !== socket));
socket.send({ type: WebSocketMessageType.ROOM_LEFT, roomId });
return room;
}
function deleteRoom(roomId: string) {
rooms.delete(roomId);
}
export function confgiureWebsocketServer(wss: WebSocketServer) {
wss.on('connection', ws => {
// complains about dispatchEvent being undefined
// @ts-ignore
let socket = new Socket(ws);
// Handle messages from the client
ws.on('message', async event => {
let message: WebSocketMessage | undefined = undefined;
if (event instanceof Buffer) { // Assuming JSON is sent as a string
try {
message = JSON.parse(Buffer.from(event).toString());
} catch (e) {
console.error("Error parsing JSON:", e);
}
}
if (message === undefined) {
console.log("Received non-JSON message:", event);
// If the message is not JSON, send an error message
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE });
return;
}
console.log("Received message:", message);
let room: ServerRoom | undefined = undefined;
switch (message.type) {
case WebSocketMessageType.CREATE_ROOM:
if (!message.nonce || !message.challenge) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MISSING_DATA });
return;
}
if (!await validateChallenge(message.challenge, message.nonce)) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.INVALID_CHALLENGE });
return;
}
// else, create a new room
try {
if (message.roomName) {
// sanitize the room name
message.roomName = message.roomName.toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with -
.replace(/[^\w-]+/g, '') // Remove all non-word chars
.replace(/--+/g, '-') // Replace multiple - with single -
.replace(/^-+/, '') // Trim - from start of text
.replace(/-+$/, ''); // Trim - from end of text
}
await createRoom(socket, message.roomName);
} catch (e: any) {
socket.send({ type: WebSocketMessageType.ERROR, data: e.message });
throw e;
}
break;
case WebSocketMessageType.JOIN_ROOM:
if (!message.roomId || !message.nonce || !message.challenge) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MISSING_DATA });
return;
}
if (!await validateChallenge(message.challenge, message.nonce, message.roomId)) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.INVALID_CHALLENGE });
return;
}
if (rooms.get(message.roomId) == undefined) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND });
return;
}
room = await joinRoom(message.roomId, socket);
if (!room) return;
break;
case WebSocketMessageType.LEAVE_ROOM:
if (!message.roomId) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE });
return;
}
if (rooms.get(message.roomId) == undefined) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND });
return;
}
room = leaveRoom(message.roomId, socket);
if (!room) return;
break;
case WebSocketMessageType.CHECK_ROOM_EXISTS:
if (!message.roomId || !message.nonce || !message.challenge) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MISSING_DATA });
return;
}
if (!await validateChallenge(message.challenge, message.nonce, message.roomId)) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.INVALID_CHALLENGE });
return;
}
socket.send({ type: WebSocketMessageType.ROOM_STATUS, roomId: message.roomId, status: rooms.get(message.roomId) ? 'found' : 'not-found' });
break;
case WebSocketMessageType.REQUEST_CHALLENGE:
let challenge = generateChallenge();
socket.send({ type: WebSocketMessageType.CHALLENGE, challenge, difficulty: CHALLENGE_DIFFICULTY });
break;
case WebSocketMessageType.WEBRTC_OFFER:
case WebSocketMessageType.WERTC_ANSWER:
case WebSocketMessageType.WEBRTC_ICE_CANDIDATE:
// relay these messages to the other peers in the room
room = rooms.get(message.data.roomId);
if (room) {
room.forEachClient(client => {
if (client !== socket) {
client.send(message);
}
});
}
break;
default:
console.warn(`Unknown message type: ${message.type}`);
socket.send({ type: WebSocketMessageType.ERROR, data: errors.UNKNOWN_MESSAGE_TYPE });
break;
}
});
});
}

View File

@@ -1,7 +1,7 @@
import { ws } from '../stores/websocketStore';
import { WebSocketMessageType } from '../types/websocket';
import { WebRTCPacketType, type WebRTCPeerCallbacks } from '../types/webrtc';
import { ws } from '$stores/websocketStore';
import { WebSocketMessageType } from '$types/websocket';
import { WebRTCPacketType, type WebRTCPeerCallbacks } from '$types/webrtc';
import { browser } from '$app/environment';
import { createApplicationMessage, createCommit, createGroup, decodeMlsMessage, defaultCapabilities, defaultLifetime, emptyPskIndex, encodeMlsMessage, generateKeyPackage, getCiphersuiteFromName, getCiphersuiteImpl, joinGroup, processPrivateMessage, type CiphersuiteImpl, type ClientState, type Credential, type KeyPackage, type PrivateKeyPackage, type Proposal } from 'ts-mls';

332
src/lib/webrtcUtil.ts Normal file
View File

@@ -0,0 +1,332 @@
import { writable, get, type Writable } from "svelte/store";
import { WebRTCPeer } from "$lib/webrtc";
import { WebRTCPacketType } from "$types/webrtc";
import { room } from "$stores/roomStore";
import { RoomConnectionState, type Room } from "$types/websocket";
import { advertisedOffers, fileRequestIds, messages, receivedOffers } from "$stores/messageStore";
import { MessageType, type Message } from "$types/message";
import { WebSocketMessageType, type WebSocketMessage } from "$types/websocket";
import { WebBuffer } from "./buffer";
import { goto } from "$app/navigation";
export const error: Writable<string | null> = writable(null);
export let peer: Writable<WebRTCPeer | null> = writable(null);
export let isRTCConnected: Writable<boolean> = writable(false);
export let dataChannelReady: Writable<boolean> = writable(false);
export let keyExchangeDone: Writable<boolean> = writable(false);
let downloadStream: WritableStream<Uint8Array> | undefined;
let downloadWriter: WritableStreamDefaultWriter<Uint8Array<ArrayBufferLike>> | undefined;
let fileAck: Map<bigint, Writable<boolean>> = new Map();
function beforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
event.returnValue = true;
}
function onPageHide(event: PageTransitionEvent) {
if (event.persisted) {
// page is frozen, but not closed
return;
}
if (downloadWriter && !downloadWriter.closed) {
downloadWriter.abort();
}
if (downloadStream) {
downloadStream.getWriter().abort();
}
downloadStream = undefined;
downloadWriter = undefined;
}
const callbacks = {
onConnected: () => {
console.log("Connected to peer");
isRTCConnected.set(true);
},
//! TODO: come up with a more complex room system. This is largely for testing purposes
onMessage: async (message: { type: WebRTCPacketType, data: ArrayBuffer }, webRtcPeer: WebRTCPeer) => {
console.log("WebRTC Received message:", message);
if (message.type !== WebRTCPacketType.MESSAGE) {
return;
}
console.log("Received message:", message.type, new Uint8Array(message.data));
let messageBuf = new WebBuffer(message.data);
console.log("manually extracted type:", messageBuf[0]);
let messageType = messageBuf[0] as MessageType;
let messageData = messageBuf.slice(1);
let textDecoder = new TextDecoder();
console.log("Received message:", messageType, messageData);
switch (messageType) {
case MessageType.TEXT:
messages.set([...get(messages), {
initiator: false,
type: messageType,
data: textDecoder.decode(messageData.buffer),
}]);
break;
case MessageType.FILE_OFFER:
let fileSize = messageData.readBigInt64LE();
let fileNameSize = messageData.readInt16LE();
let fileName = messageData.readString(fileNameSize);
let id = messageData.readBigInt64LE();
get(receivedOffers).set(id, { name: fileName, size: fileSize });
messages.set([...get(messages), {
initiator: false,
type: messageType,
data: {
fileSize,
fileNameSize,
fileName,
id,
text: messageData.peek() ? messageData.readString() : null,
}
}]);
break;
case MessageType.FILE_REQUEST:
// the id that coresponds to our file offer
let offerId = messageData.readBigInt64LE();
if (!get(advertisedOffers).has(offerId)) {
console.error("Unknown file offer id:", offerId);
return;
}
let targetFile = get(advertisedOffers).get(offerId)!;
let fileStream = targetFile.stream();
let fileReader = fileStream.getReader();
let idleTimeout = setTimeout(() => {
console.error("Timed out waiting for file ack");
fileReader.cancel();
}, 30000);
// the id we send the file data with
let fileRequestId = messageData.readBigInt64LE();
let fileChunk = await fileReader.read();
// reactive variable to track if the peer received the chunk
fileAck.set(fileRequestId, writable(false));
function sendChunk() {
if (!fileChunk.value) {
clearTimeout(idleTimeout);
fileReader.cancel();
console.error("Chunk not set");
return;
}
// header + id + data
let fileBuf = new WebBuffer(new Uint8Array(1 + 8 + fileChunk.value.byteLength).buffer);
fileBuf.writeInt8(MessageType.FILE);
fileBuf.writeBigInt64LE(fileRequestId);
fileBuf.write(fileChunk.value);
webRtcPeer.send(fileBuf.buffer, WebRTCPacketType.MESSAGE);
}
sendChunk();
let unsubscribe = fileAck.get(fileRequestId)!.subscribe(async (value) => {
if (!value) {
return;
}
fileChunk = await fileReader.read();
if (fileChunk.done) {
// send the done message
let fileDoneBuf = new WebBuffer(new ArrayBuffer(1 + 8));
fileDoneBuf.writeInt8(MessageType.FILE_DONE);
fileDoneBuf.writeBigInt64LE(fileRequestId);
webRtcPeer.send(fileDoneBuf.buffer, WebRTCPacketType.MESSAGE);
// cleanup
fileReader.cancel();
fileAck.delete(fileRequestId);
clearTimeout(idleTimeout);
unsubscribe();
return;
}
sendChunk();
fileAck.get(fileRequestId)!.set(false);
clearTimeout(idleTimeout);
idleTimeout = setTimeout(() => {
console.error("Timed out waiting for file ack");
fileReader.cancel();
}, 30000);
});
console.log("Received file request");
break;
case MessageType.FILE:
let requestId = messageData.readBigInt64LE();
let receivedOffserId = get(fileRequestIds).get(requestId);
if (!receivedOffserId) {
console.error("Received file message for unknown file id:", requestId);
return;
}
let file = get(receivedOffers).get(receivedOffserId);
if (!file) {
console.error("Unknown file id:", requestId);
return;
}
if (downloadStream === undefined) {
window.addEventListener("pagehide", onPageHide);
window.addEventListener("beforeunload", beforeUnload);
// @ts-ignore
downloadStream = window.streamSaver.createWriteStream(file.name, { size: Number(file.size) });
downloadWriter = downloadStream!.getWriter();
}
await downloadWriter!.write(new Uint8Array(messageData.read()));
let fileAckBuf = new WebBuffer(new ArrayBuffer(1 + 8));
fileAckBuf.writeInt8(MessageType.FILE_ACK);
fileAckBuf.writeBigInt64LE(requestId);
webRtcPeer.send(fileAckBuf.buffer, WebRTCPacketType.MESSAGE);
break;
case MessageType.FILE_DONE:
console.log("Received file done");
let fileDoneId = messageData.readBigInt64LE();
if (!get(fileRequestIds).has(fileDoneId)) {
console.error("Unknown file done id:", fileDoneId);
return;
}
window.removeEventListener("pagehide", onPageHide);
window.removeEventListener("beforeunload", beforeUnload);
if (downloadWriter) {
downloadWriter.close();
downloadWriter = undefined;
downloadStream = undefined;
}
break;
case MessageType.FILE_ACK:
console.log("Received file ack");
let fileAckId = messageData.readBigInt64LE();
if (!fileAck.has(fileAckId)) {
console.error("Unknown file ack id:", fileAckId);
return;
}
fileAck.get(fileAckId)!.set(true);
break;
default:
console.warn("Unhandled message type:", messageType);
break;
}
},
onDataChannelStateChange: (state: boolean) => {
console.log(`Data channel ${state ? "open" : "closed"}`);
dataChannelReady.set(state);
},
onKeyExchangeDone: async () => {
console.log("Key exchange done");
keyExchangeDone.set(true);
},
onNegotiationNeeded: async () => {
console.log("Negotiation needed");
await get(peer)?.createOffer();
},
onError: (error: any) => {
console.error("Error:", error);
messages.set([...get(messages), { initiator: false, type: MessageType.ERROR, data: error }]);
},
};
export async function handleMessage(event: MessageEvent) {
console.log("Message received:", event.data, typeof event.data);
const message: WebSocketMessage = JSON.parse(event.data);
switch (message.type) {
case WebSocketMessageType.ROOM_CREATED:
console.log("Room created:", message.data);
room.set({ id: message.data, host: true, RTCConnectionReady: false, connectionState: RoomConnectionState.CONNECTED, participants: 1 });
goto(`/${message.data}`);
return;
case WebSocketMessageType.JOIN_ROOM:
console.log("new client joined room");
room.update((room) => ({ ...room, participants: room.participants + 1 }));
return;
case WebSocketMessageType.ROOM_JOINED:
// TODO: if a client disconnects, we need to resync the room state
room.set({ host: false, id: message.roomId, RTCConnectionReady: false, connectionState: RoomConnectionState.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);
return;
case WebSocketMessageType.ROOM_READY:
let roomId = get(room).id;
if (roomId === null) {
console.error("Room not set");
return;
}
room.update(r => ({ ...r, RTCConnectionReady: true }));
console.log("Creating peer");
peer.set(new WebRTCPeer(
roomId,
message.data.isInitiator,
callbacks,
));
await get(peer)!.initialize();
return;
}
if (!get(peer)) {
console.debug("Unhandled message type:", message.type);
return;
}
switch (message.type) {
case WebSocketMessageType.WEBRTC_OFFER:
console.log("Received offer");
await get(peer)?.setRemoteDescription(
new RTCSessionDescription(message.data.sdp),
);
await get(peer)?.createAnswer();
return;
case WebSocketMessageType.WERTC_ANSWER:
console.log("Received answer");
await get(peer)?.setRemoteDescription(
new RTCSessionDescription(message.data.sdp),
);
return;
case WebSocketMessageType.WEBRTC_ICE_CANDIDATE:
console.log("Received ICE candidate");
await get(peer)?.addIceCandidate(message.data.candidate);
return;
default:
console.debug(
`Unknown message type: ${message.type} from ${get(room).id}`,
);
}
}