proof of work, bug fixes, reorg, more
This commit is contained in:
197
src/lib/buffer.ts
Normal file
197
src/lib/buffer.ts
Normal 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
45
src/lib/challenge.ts
Normal 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
65
src/lib/liveMap.ts
Normal 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
25
src/lib/powUtil.ts
Normal 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;
|
||||
}
|
||||
|
||||
332
src/lib/server/websocketHandler.ts
Normal file
332
src/lib/server/websocketHandler.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
332
src/lib/webrtcUtil.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user