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

View File

@@ -5,13 +5,13 @@
"name": "wormhole", "name": "wormhole",
"dependencies": { "dependencies": {
"@hpke/chacha20poly1305": "^1.7.1", "@hpke/chacha20poly1305": "^1.7.1",
"@hpke/core": "^1.7.4",
"@hpke/hybridkem-x-wing": "^0.6.1", "@hpke/hybridkem-x-wing": "^0.6.1",
"@noble/ciphers": "^1.3.0", "@noble/ciphers": "^1.3.0",
"@noble/curves": "^1.9.0", "@noble/curves": "^1.9.0",
"@sveltejs/adapter-node": "^5.3.1", "@sveltejs/adapter-node": "^5.3.1",
"@types/streamsaver": "^2.0.5", "@types/streamsaver": "^2.0.5",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"i": "^0.3.7",
"polka": "^0.5.2", "polka": "^0.5.2",
"streamsaver": "^2.0.6", "streamsaver": "^2.0.6",
"ts-mls": "^1.1.0", "ts-mls": "^1.1.0",
@@ -99,8 +99,6 @@
"@hpke/hybridkem-x-wing": ["@hpke/hybridkem-x-wing@0.6.1", "", { "dependencies": { "@hpke/common": "^1.8.1", "@hpke/dhkem-x25519": "^1.6.4", "mlkem": "^2.5.0" } }, "sha512-mNdGapyHPw9gEicUlBYlWGjOpWmQyC49dEqLm5QtGZOSjIVSjSTBX/Bq2VxXNTeNdsRYIpPOalTwYbop/+4Ykw=="], "@hpke/hybridkem-x-wing": ["@hpke/hybridkem-x-wing@0.6.1", "", { "dependencies": { "@hpke/common": "^1.8.1", "@hpke/dhkem-x25519": "^1.6.4", "mlkem": "^2.5.0" } }, "sha512-mNdGapyHPw9gEicUlBYlWGjOpWmQyC49dEqLm5QtGZOSjIVSjSTBX/Bq2VxXNTeNdsRYIpPOalTwYbop/+4Ykw=="],
"@hpke/ml-kem": ["@hpke/ml-kem@0.2.1", "", { "dependencies": { "@hpke/common": "^1.8.1", "mlkem": "^2.5.0" } }, "sha512-9Hmf2fO8W45/h2COdD8+RAaszI2dw9/Id0lwrJD7rEwFbNki7lxb1HyEnqUuHz9ipL2D3q0L6egFKZbhBwj/5A=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -295,8 +293,6 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"i": ["i@0.3.7", "", {}, "sha512-FYz4wlXgkQwIPqhzC5TdNMLSE5+GS1IIDJZY/1ZiEPCT2S3COUVZeT5OW4BmW4r5LHLQuOosSwsvnroG9GR59Q=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="], "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
@@ -409,6 +405,8 @@
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"zimmerframe": ["zimmerframe@1.1.2", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="], "zimmerframe": ["zimmerframe@1.1.2", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="],
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], "@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],

View File

@@ -25,13 +25,13 @@
}, },
"dependencies": { "dependencies": {
"@hpke/chacha20poly1305": "^1.7.1", "@hpke/chacha20poly1305": "^1.7.1",
"@hpke/core": "^1.7.4",
"@hpke/hybridkem-x-wing": "^0.6.1", "@hpke/hybridkem-x-wing": "^0.6.1",
"@noble/ciphers": "^1.3.0", "@noble/ciphers": "^1.3.0",
"@noble/curves": "^1.9.0", "@noble/curves": "^1.9.0",
"@sveltejs/adapter-node": "^5.3.1", "@sveltejs/adapter-node": "^5.3.1",
"@types/streamsaver": "^2.0.5", "@types/streamsaver": "^2.0.5",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"i": "^0.3.7",
"polka": "^0.5.2", "polka": "^0.5.2",
"streamsaver": "^2.0.6", "streamsaver": "^2.0.6",
"ts-mls": "^1.1.0", "ts-mls": "^1.1.0",

View File

@@ -2,7 +2,7 @@ import { handler } from './build/handler.js'; // Adjust path as needed
import http from 'http'; import http from 'http';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import polka from 'polka'; import polka from 'polka';
import { confgiureWebsocketServer } from './server/websocketHandler.ts' import { confgiureWebsocketServer } from './src/lib/server/websocketHandler.ts'
const server = http.createServer(); const server = http.createServer();
const app = polka({ server }); const app = polka({ server });

View File

@@ -1,5 +1,13 @@
<script lang="ts">
let { size }: { size?: number } = $$props;
if (!size) {
size = 20;
}
</script>
<svg <svg
class="animate-spin -ml-1 mr-3 h-5 w-5" class="animate-spin -ml-1 mr-3"
style="width: {size}px; height: {size}px"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"

Before

Width:  |  Height:  |  Size: 481 B

After

Width:  |  Height:  |  Size: 638 B

View File

@@ -1,23 +1,23 @@
<script lang="ts"> <script lang="ts">
import { derived, writable, type Writable } from "svelte/store"; import { derived, writable, type Writable } from "svelte/store";
import { WebsocketConnectionState, ws } from "../stores/websocketStore"; import { WebsocketConnectionState, ws } from "$stores/websocketStore";
import { import {
isRTCConnected, isRTCConnected,
dataChannelReady, dataChannelReady,
peer, peer,
keyExchangeDone, keyExchangeDone,
} from "../utils/webrtcUtil"; } from "$lib/webrtcUtil";
import { import {
advertisedOffers, advertisedOffers,
fileRequestIds, fileRequestIds,
messages, messages,
receivedOffers, receivedOffers,
} from "../stores/messageStore"; } from "$stores/messageStore";
import { WebRTCPacketType } from "../types/webrtc"; import { WebRTCPacketType } from "$types/webrtc";
import { RoomConnectionState, type Room } from "../types/websocket"; import { RoomConnectionState, type Room } from "$types/websocket";
import { MessageType } from "../types/message"; import { MessageType } from "$types/message";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { WebBuffer } from "../utils/buffer"; import { WebBuffer } from "../lib/buffer";
let inputMessage: Writable<string> = writable(""); let inputMessage: Writable<string> = writable("");
let inputFile: Writable<FileList | null | undefined> = writable(null); let inputFile: Writable<FileList | null | undefined> = writable(null);

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,
};
}

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

@@ -1,16 +1,15 @@
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { Socket, WebSocketMessageType, type WebSocketMessage } from "../src/types/websocket"; import { Socket, WebSocketMessageType, type WebSocketMessage } from "../../types/websocket.ts";
import { LiveMap } from '../src/utils/liveMap.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 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 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']
enum ErrorCode {
ROOM_NOT_FOUND,
}
const errors = { const errors = {
MALFORMED_MESSAGE: "Invalid message", 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_NOT_FOUND: "Room does not exist",
ROOM_FULL: "Room is full", ROOM_FULL: "Room is full",
UNKNOWN_MESSAGE_TYPE: "Unknown message type", UNKNOWN_MESSAGE_TYPE: "Unknown message type",
@@ -106,13 +105,13 @@ async function joinRoom(roomId: string, socket: Socket, initial?: boolean): Prom
// for some reason, when you filter the array when the length is 1 it stays at 1, but we *know* that if its 1 // 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 // then when this client disconnects, the room should be deleted since the room is empty
if (room.length === 1) { if (room.length === 1) {
// give a 5 second grace period before deleting the room // give a 60 second grace period before deleting the room
setTimeout(() => { setTimeout(() => {
if (rooms.get(roomId)?.length === 1) { if (rooms.get(roomId)?.length === 1) {
console.log("Room is empty, deleting"); console.log("Room is empty, deleting");
deleteRoom(roomId); deleteRoom(roomId);
} }
}, 5000) }, 60000)
return; return;
} }
@@ -132,6 +131,39 @@ async function joinRoom(roomId: string, socket: Socket, initial?: boolean): Prom
return room; 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 { function leaveRoom(roomId: string, socket: Socket): ServerRoom | undefined {
let room = rooms.get(roomId); let room = rooms.get(roomId);
console.log(room?.length); console.log(room?.length);
@@ -189,10 +221,22 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
return; return;
} }
console.log("Received message:", message);
let room: ServerRoom | undefined = undefined; let room: ServerRoom | undefined = undefined;
switch (message.type) { switch (message.type) {
case WebSocketMessageType.CREATE_ROOM: 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 // else, create a new room
try { try {
if (message.roomName) { if (message.roomName) {
@@ -212,8 +256,13 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
} }
break; break;
case WebSocketMessageType.JOIN_ROOM: case WebSocketMessageType.JOIN_ROOM:
if (!message.roomId) { if (!message.roomId || !message.nonce || !message.challenge) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE }); 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; return;
} }
@@ -237,9 +286,27 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
return; return;
} }
room = await leaveRoom(message.roomId, socket); room = leaveRoom(message.roomId, socket);
if (!room) return; 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; break;
case WebSocketMessageType.WEBRTC_OFFER: case WebSocketMessageType.WEBRTC_OFFER:
case WebSocketMessageType.WERTC_ANSWER: case WebSocketMessageType.WERTC_ANSWER:

View File

@@ -1,7 +1,7 @@
import { ws } from '../stores/websocketStore'; import { ws } from '$stores/websocketStore';
import { WebSocketMessageType } from '../types/websocket'; import { WebSocketMessageType } from '$types/websocket';
import { WebRTCPacketType, type WebRTCPeerCallbacks } from '../types/webrtc'; import { WebRTCPacketType, type WebRTCPeerCallbacks } from '$types/webrtc';
import { browser } from '$app/environment'; 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'; 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';

View File

@@ -1,11 +1,11 @@
import { writable, get, type Writable } from "svelte/store"; import { writable, get, type Writable } from "svelte/store";
import { WebRTCPeer } from "$lib/webrtc"; import { WebRTCPeer } from "$lib/webrtc";
import { CHUNK_SIZE, WebRTCPacketType } from "../types/webrtc"; import { WebRTCPacketType } from "$types/webrtc";
import { room } from "../stores/roomStore"; import { room } from "$stores/roomStore";
import { RoomConnectionState, type Room } from "../types/websocket"; import { RoomConnectionState, type Room } from "$types/websocket";
import { advertisedOffers, fileRequestIds, messages, receivedOffers } from "../stores/messageStore"; import { advertisedOffers, fileRequestIds, messages, receivedOffers } from "$stores/messageStore";
import { MessageType, type Message } from "../types/message"; import { MessageType, type Message } from "$types/message";
import { WebSocketMessageType, type WebSocketMessage } from "../types/websocket"; import { WebSocketMessageType, type WebSocketMessage } from "$types/websocket";
import { WebBuffer } from "./buffer"; import { WebBuffer } from "./buffer";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -186,8 +186,10 @@ const callbacks = {
if (downloadStream === undefined) { if (downloadStream === undefined) {
window.addEventListener("pagehide", onPageHide); window.addEventListener("pagehide", onPageHide);
window.addEventListener("beforeunload", beforeUnload); window.addEventListener("beforeunload", beforeUnload);
// @ts-ignore
downloadStream = window.streamSaver.createWriteStream(file.name, { size: Number(file.size) }); downloadStream = window.streamSaver.createWriteStream(file.name, { size: Number(file.size) });
downloadWriter = downloadStream.getWriter(); downloadWriter = downloadStream!.getWriter();
} }
await downloadWriter!.write(new Uint8Array(messageData.read())); await downloadWriter!.write(new Uint8Array(messageData.read()));
@@ -249,6 +251,7 @@ const callbacks = {
}, },
}; };
export async function handleMessage(event: MessageEvent) { export async function handleMessage(event: MessageEvent) {
console.log("Message received:", event.data, typeof event.data); console.log("Message received:", event.data, typeof event.data);
const message: WebSocketMessage = JSON.parse(event.data); const message: WebSocketMessage = JSON.parse(event.data);
@@ -299,7 +302,7 @@ export async function handleMessage(event: MessageEvent) {
} }
if (!get(peer)) { if (!get(peer)) {
console.error("Unknown message type:", message.type); console.debug("Unhandled message type:", message.type);
return; return;
} }
@@ -322,7 +325,7 @@ export async function handleMessage(event: MessageEvent) {
await get(peer)?.addIceCandidate(message.data.candidate); await get(peer)?.addIceCandidate(message.data.candidate);
return; return;
default: default:
console.warn( console.debug(
`Unknown message type: ${message.type} from ${get(room).id}`, `Unknown message type: ${message.type} from ${get(room).id}`,
); );
} }

View File

@@ -2,8 +2,8 @@
import "../app.css"; import "../app.css";
import favicon from "$lib/assets/favicon.svg"; import favicon from "$lib/assets/favicon.svg";
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { WebsocketConnectionState, ws } from "../stores/websocketStore"; import { WebsocketConnectionState, ws } from "$stores/websocketStore";
import { room } from "../stores/roomStore"; import { room } from "$stores/roomStore";
onMount(() => { onMount(() => {
ws.connect(); ws.connect();
@@ -35,6 +35,12 @@
crossorigin="anonymous" crossorigin="anonymous"
href="/fonts/InstrumentSans-VariableFont_wdth,wght.woff2" href="/fonts/InstrumentSans-VariableFont_wdth,wght.woff2"
/> />
{#if process.env.NODE_ENV !== "production"}
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>
eruda.init();
</script>
{/if}
<script <script
src="https://cdn.jsdelivr.net/npm/web-streams-polyfill@2.0.2/dist/ponyfill.min.js" src="https://cdn.jsdelivr.net/npm/web-streams-polyfill@2.0.2/dist/ponyfill.min.js"
></script> ></script>
@@ -52,7 +58,7 @@
</div> </div>
<nav> <nav>
<a <a
href="https://github.com" href="https://github.com/juls0730/noctis"
target="_blank" target="_blank"
rel="noopener noreferrer">GitHub</a rel="noopener noreferrer">GitHub</a
> >

View File

@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { ws } from "../stores/websocketStore"; import { WebsocketConnectionState, ws } from "$stores/websocketStore";
import { WebSocketMessageType } from "../types/websocket"; import { WebSocketMessageType } from "$types/websocket";
import { writable, type Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
import LoadingSpinner from "../components/LoadingSpinner.svelte"; import LoadingSpinner from "$components/LoadingSpinner.svelte";
import { doChallenge } from "$lib/challenge";
let roomName: Writable<string> = writable(""); let roomName: Writable<string> = writable("");
let roomLoading: Writable<boolean> = writable(false); let roomLoading: Writable<boolean> = writable(false);
@@ -11,12 +12,20 @@
roomLoading.set(true); roomLoading.set(true);
let roomId = $roomName.trim() === "" ? undefined : $roomName.trim(); let roomId = $roomName.trim() === "" ? undefined : $roomName.trim();
doChallenge().then(async (challengeResult) => {
if (!challengeResult) {
return;
}
ws.send({ ws.send({
type: WebSocketMessageType.CREATE_ROOM, type: WebSocketMessageType.CREATE_ROOM,
roomName: roomId, roomName: roomId,
nonce: challengeResult.nonce,
challenge: challengeResult.challenge,
}); });
// todo: redirect to the room
console.log("Created room:", roomId); console.log("Created room:", roomId);
});
} }
let showRoomNameInput: Writable<boolean> = writable(false); let showRoomNameInput: Writable<boolean> = writable(false);
@@ -36,9 +45,15 @@
<form class="flex flex-col gap-5" id="roomForm"> <form class="flex flex-col gap-5" id="roomForm">
<button <button
onclick={createRoom} onclick={createRoom}
class="py-4 px-8 text-xl font-semibold bg-accent text-[#121826] rounded-lg cursor-pointer transition-[background-color,_translate,_box-shadow] ease-out duration-200 w-full inline-flex justify-center items-center gap-2.5 hover:bg-[#00f0c8] hover:-translate-y-1 hover:shadow-md shadow-accent/20" disabled={$ws.status !==
WebsocketConnectionState.CONNECTED || $roomLoading}
class="py-4 px-8 text-xl font-semibold bg-accent text-[#121826] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg cursor-pointer transition-[background-color,_translate,_box-shadow] ease-out duration-200 w-full inline-flex justify-center items-center gap-2.5 hover:bg-[#00f0c8] hover:-translate-y-1 hover:shadow-md shadow-accent/20"
> >
{#if $roomLoading} {#if $ws.status !== WebsocketConnectionState.CONNECTED}
<span class="flex items-center"
><LoadingSpinner /> Connecting to server...</span
>
{:else if $roomLoading}
<span class="flex items-center" <span class="flex items-center"
><LoadingSpinner /> Creating Room...</span ><LoadingSpinner /> Creating Room...</span
> >
@@ -232,7 +247,7 @@
</div> </div>
</section> </section>
<footer class="px-20 text-center border-t border-[#21293b]"> <footer class="px-20 pt-3 text-center border-t border-[#21293b]">
<div class="max-w-6xl px-10 mx-auto"> <div class="max-w-6xl px-10 mx-auto">
<p> <p>
&copy; {new Date().getFullYear()} Noctis - MIT License &copy; {new Date().getFullYear()} Noctis - MIT License

View File

@@ -1,19 +1,23 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { room } from "../../stores/roomStore"; import { room } from "$stores/roomStore";
import { ws } from "../../stores/websocketStore"; import { WebsocketConnectionState, ws } from "$stores/websocketStore";
import { WebSocketMessageType } from "../../types/websocket"; import { WebSocketMessageType } from "$types/websocket";
import { dataChannelReady, error } from "../../utils/webrtcUtil"; import { dataChannelReady, error } from "$lib/webrtcUtil";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import RtcMessage from "../../components/RTCMessage.svelte"; import RtcMessage from "$components/RTCMessage.svelte";
import { page } from "$app/state";
import LoadingSpinner from "$components/LoadingSpinner.svelte";
import { hashStringSHA256, solveChallenge } from "$lib/powUtil";
import { doChallenge } from "$lib/challenge";
const { roomId } = page.params;
let isHost = $room.host === true; let isHost = $derived($room.host === true);
let roomExists: boolean | undefined = $state(undefined);
let awaitingJoinConfirmation = !isHost; let awaitingJoinConfirmation = $derived(!isHost);
let roomLink = ""; let roomLink = $state("");
let copyButtonText = "Copy Link"; let copyButtonText = $state("Copy Link");
export let data: { roomId: string };
const { roomId } = data;
onMount(() => { onMount(() => {
error.set(null); error.set(null);
@@ -30,12 +34,23 @@
}); });
} }
function handleConfirmJoin() { async function handleConfirmJoin() {
awaitingJoinConfirmation = false; awaitingJoinConfirmation = false;
if (!roomId) {
return;
}
let challengeResult = await doChallenge(roomId);
if (!challengeResult) {
return;
}
ws.send({ ws.send({
type: WebSocketMessageType.JOIN_ROOM, type: WebSocketMessageType.JOIN_ROOM,
roomId: roomId, roomId: roomId!,
nonce: challengeResult.nonce,
challenge: challengeResult.challenge,
}); });
} }
@@ -56,6 +71,42 @@
window.location.href = "/"; window.location.href = "/";
} }
} }
ws.subscribe(async (newWs) => {
if (newWs.status === WebsocketConnectionState.CONNECTED) {
if (!awaitingJoinConfirmation) {
return;
}
if (!roomId) {
return;
}
let challengeResult = await doChallenge(roomId);
if (challengeResult) {
let unsubscribe = ws.handleEvent(
WebSocketMessageType.ROOM_STATUS,
(value) => {
if (value.status === "found") {
unsubscribe();
roomExists = true;
} else if (value.status === "not-found") {
unsubscribe();
roomExists = false;
}
},
);
ws.send({
type: WebSocketMessageType.CHECK_ROOM_EXISTS,
roomId: roomId,
nonce: challengeResult.nonce,
challenge: challengeResult.challenge,
});
}
}
});
</script> </script>
<div class="max-w-6xl px-5 mx-auto flex flex-col items-center"> <div class="max-w-6xl px-5 mx-auto flex flex-col items-center">
@@ -97,6 +148,23 @@
<RtcMessage {room} /> <RtcMessage {room} />
{/if} {/if}
{:else if awaitingJoinConfirmation} {:else if awaitingJoinConfirmation}
{#if $ws.status !== WebsocketConnectionState.CONNECTED || roomExists === undefined}
<h2 class="text-3xl font-bold text-white mb-2">
<span class="flex items-center"
><LoadingSpinner size="24" /> Connecting to server...</span
>
</h2>
<p class="!text-paragraph">
click <a href="/">here</a> to go back to the homepage
</p>
{:else if roomExists === false}
<h2 class="text-3xl font-bold text-white mb-2">
That room does not exist.
</h2>
<p class="!text-paragraph">
click <a href="/">here</a> to go back to the homepage
</p>
{:else}
<h2 class="text-3xl font-bold text-white mb-2"> <h2 class="text-3xl font-bold text-white mb-2">
You're invited to chat. You're invited to chat.
</h2> </h2>
@@ -114,6 +182,7 @@
Decline Decline
</button> </button>
</div> </div>
{/if}
{:else} {:else}
<RtcMessage {room} /> <RtcMessage {room} />
{/if} {/if}

View File

@@ -1,5 +1,5 @@
import { writable, type Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
import type { Message } from "../types/message"; import type { Message } from "$types/message";
export let messages: Writable<Message[]> = writable([]); export let messages: Writable<Message[]> = writable([]);
export let advertisedOffers = writable(new Map<bigint, File>()); export let advertisedOffers = writable(new Map<bigint, File>());

View File

@@ -1,5 +1,5 @@
import { writable, type Writable } from 'svelte/store'; import { writable, type Writable } from 'svelte/store';
import { RoomConnectionState } from '../types/websocket'; import { RoomConnectionState } from '$types/websocket';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
export interface Room { export interface Room {

View File

@@ -1,7 +1,7 @@
import { get, writable, type Readable, type Writable } from 'svelte/store'; import { get, writable, type Readable, type Writable } from 'svelte/store';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { Socket, type WebSocketMessage } from '../types/websocket'; import { Socket, WebSocketMessageType, type WebSocketMessage } from '$types/websocket';
import { handleMessage } from '../utils/webrtcUtil'; import { handleMessage } from '../lib/webrtcUtil';
export enum WebsocketConnectionState { export enum WebsocketConnectionState {
DISCONNECTED, DISCONNECTED,
@@ -21,6 +21,7 @@ interface WebSocketStore extends Readable<WebSocketStoreValue> {
connect: () => void; connect: () => void;
disconnect: () => void; disconnect: () => void;
send: (message: WebSocketMessage) => void; send: (message: WebSocketMessage) => void;
handleEvent<T extends WebSocketMessageType>(messageType: T, func: (message: WebSocketMessage & { type: T }) => void): () => void;
} }
// TODO: handle reconnection logic to room elsewhere (not implemented here) // TODO: handle reconnection logic to room elsewhere (not implemented here)
@@ -94,11 +95,23 @@ function createWebSocketStore(messageHandler: MessageHandler): WebSocketStore {
}); });
}; };
function handleEvent<T extends WebSocketMessageType>(messageType: T, func: (message: WebSocketMessage & { type: T }) => void) {
let socket = get({ subscribe }).socket;
if (!socket) {
return () => { };
}
// TODO: why does the typescript compiler think this is invalid?
return socket.handleEvent<T>(messageType, func)
}
return { return {
subscribe, subscribe,
connect, connect,
disconnect, disconnect,
send, send,
handleEvent,
}; };
} }

View File

@@ -16,12 +16,16 @@ export enum WebSocketMessageType {
CREATE_ROOM = "create", CREATE_ROOM = "create",
JOIN_ROOM = "join", JOIN_ROOM = "join",
LEAVE_ROOM = "leave", LEAVE_ROOM = "leave",
CHECK_ROOM_EXISTS = "check",
REQUEST_CHALLENGE = "request-challenge",
// response messages // response messages
ROOM_CREATED = "created", ROOM_CREATED = "created",
ROOM_JOINED = "joined", ROOM_JOINED = "joined",
ROOM_LEFT = "left", ROOM_LEFT = "left",
ROOM_READY = "ready", ROOM_READY = "ready",
ROOM_STATUS = "status",
CHALLENGE = "challenge",
// webrtc messages // webrtc messages
WEBRTC_OFFER = "offer", WEBRTC_OFFER = "offer",
@@ -31,14 +35,19 @@ export enum WebSocketMessageType {
ERROR = "error", ERROR = "error",
} }
// TODO: name the interfaces better
export type WebSocketMessage = export type WebSocketMessage =
| CreateRoomMessage | CreateRoomMessage
| JoinRoomMessage | JoinRoomMessage
| LeaveRoomMessage | LeaveRoomMessage
| CheckRoomExistsMessage
| RequestChallengeMessage
| RoomCreatedMessage | RoomCreatedMessage
| RoomJoinedMessage | RoomJoinedMessage
| RoomLeftMessage | RoomLeftMessage
| RoomStatusMessage
| RoomReadyMessage | RoomReadyMessage
| ChallengeMessage
| OfferMessage | OfferMessage
| AnswerMessage | AnswerMessage
| IceCandidateMessage | IceCandidateMessage
@@ -49,14 +58,20 @@ interface ErrorMessage {
data: string; data: string;
} }
// ====== Query Messages ======
interface CreateRoomMessage { interface CreateRoomMessage {
type: WebSocketMessageType.CREATE_ROOM; type: WebSocketMessageType.CREATE_ROOM;
roomName?: string; roomName?: string;
nonce: string;
challenge: string;
} }
// TODO: this is used as a query message, but it's also used as a response message
interface JoinRoomMessage { interface JoinRoomMessage {
type: WebSocketMessageType.JOIN_ROOM; type: WebSocketMessageType.JOIN_ROOM;
roomId: string; roomId: string;
nonce?: string;
challenge?: string;
} }
interface LeaveRoomMessage { interface LeaveRoomMessage {
@@ -64,6 +79,20 @@ interface LeaveRoomMessage {
roomId: string; roomId: string;
} }
interface CheckRoomExistsMessage {
type: WebSocketMessageType.CHECK_ROOM_EXISTS;
// if sha256(roomId + challenge + nonce) has a certain number of leading zeros, then we can give the status to the user
roomId: string;
nonce: string;
challenge: string;
}
interface RequestChallengeMessage {
type: WebSocketMessageType.REQUEST_CHALLENGE;
}
// ====== Response Messages ======
interface RoomCreatedMessage { interface RoomCreatedMessage {
type: WebSocketMessageType.ROOM_CREATED; type: WebSocketMessageType.ROOM_CREATED;
data: string; data: string;
@@ -80,6 +109,12 @@ interface RoomLeftMessage {
roomId: string; roomId: string;
} }
interface RoomStatusMessage {
type: WebSocketMessageType.ROOM_STATUS;
roomId: string;
status: 'found' | 'not-found';
}
interface RoomReadyMessage { interface RoomReadyMessage {
type: WebSocketMessageType.ROOM_READY; type: WebSocketMessageType.ROOM_READY;
data: { data: {
@@ -87,6 +122,14 @@ interface RoomReadyMessage {
}; };
} }
interface ChallengeMessage {
type: WebSocketMessageType.CHALLENGE;
challenge: string;
difficulty: number;
}
// ====== WebRTC signaling messages ======
// as the server, we dont do anything with these messages other than relay them to the other peers in the room
interface OfferMessage { interface OfferMessage {
type: WebSocketMessageType.WEBRTC_OFFER; type: WebSocketMessageType.WEBRTC_OFFER;
data: { data: {
@@ -122,6 +165,9 @@ export class Socket {
public addEventListener: typeof WebSocket.prototype.addEventListener; public addEventListener: typeof WebSocket.prototype.addEventListener;
public removeEventListener: typeof WebSocket.prototype.removeEventListener; public removeEventListener: typeof WebSocket.prototype.removeEventListener;
public close: typeof WebSocket.prototype.close; public close: typeof WebSocket.prototype.close;
// maps WebSocketMessageType to an array of functions that handle that message
// this allows for consumbers to subscribe to a specific message type and handle it themselves
private functionStack: Map<WebSocketMessageType, Function[]>;
constructor(webSocket: WebSocket) { constructor(webSocket: WebSocket) {
this.ws = webSocket; this.ws = webSocket;
@@ -130,6 +176,18 @@ export class Socket {
console.log("WebSocket opened"); console.log("WebSocket opened");
}); });
this.functionStack = new Map();
this.ws.addEventListener("message", async (event) => {
console.log("WebSocket received message:", event.data);
const message: WebSocketMessage = JSON.parse(event.data);
if (this.functionStack.has(message.type)) {
for (let func of this.functionStack.get(message.type)!) {
func(message);
}
}
});
this.addEventListener = this.ws.addEventListener.bind(this.ws); this.addEventListener = this.ws.addEventListener.bind(this.ws);
this.removeEventListener = this.ws.removeEventListener.bind(this.ws); this.removeEventListener = this.ws.removeEventListener.bind(this.ws);
@@ -145,4 +203,15 @@ export class Socket {
this.ws.send(JSON.stringify(message)); this.ws.send(JSON.stringify(message));
} }
public handleEvent<T extends WebSocketMessageType>(messageType: T, func: (message: WebSocketMessage & { type: T }) => void): () => void {
if (!this.functionStack.has(messageType)) {
this.functionStack.set(messageType, []);
}
this.functionStack.get(messageType)!.push(func);
return () => {
this.functionStack.get(messageType)!.splice(this.functionStack.get(messageType)!.indexOf(func), 1);
}
}
} }

View File

@@ -1,5 +1,5 @@
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { confgiureWebsocketServer } from '../server/websocketHandler.ts'; import { confgiureWebsocketServer } from './lib/server/websocketHandler.ts';
import type { ViteDevServer } from "vite"; import type { ViteDevServer } from "vite";

View File

@@ -10,7 +10,13 @@ const config = {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter. // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters. // See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter() adapter: adapter(),
alias: {
$stores: './src/stores',
$components: './src/components',
$types: './src/types',
'$lib/server': './src/lib/server',
}
}, },
compilerOptions: { compilerOptions: {
experimental: { experimental: {