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",
"dependencies": {
"@hpke/chacha20poly1305": "^1.7.1",
"@hpke/core": "^1.7.4",
"@hpke/hybridkem-x-wing": "^0.6.1",
"@noble/ciphers": "^1.3.0",
"@noble/curves": "^1.9.0",
"@sveltejs/adapter-node": "^5.3.1",
"@types/streamsaver": "^2.0.5",
"@types/ws": "^8.18.1",
"i": "^0.3.7",
"polka": "^0.5.2",
"streamsaver": "^2.0.6",
"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/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=="],
"@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=="],
"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-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=="],
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"zimmerframe": ["zimmerframe@1.1.2", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="],
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],

View File

@@ -25,13 +25,13 @@
},
"dependencies": {
"@hpke/chacha20poly1305": "^1.7.1",
"@hpke/core": "^1.7.4",
"@hpke/hybridkem-x-wing": "^0.6.1",
"@noble/ciphers": "^1.3.0",
"@noble/curves": "^1.9.0",
"@sveltejs/adapter-node": "^5.3.1",
"@types/streamsaver": "^2.0.5",
"@types/ws": "^8.18.1",
"i": "^0.3.7",
"polka": "^0.5.2",
"streamsaver": "^2.0.6",
"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 { WebSocketServer } from 'ws';
import polka from 'polka';
import { confgiureWebsocketServer } from './server/websocketHandler.ts'
import { confgiureWebsocketServer } from './src/lib/server/websocketHandler.ts'
const server = http.createServer();
const app = polka({ server });

View File

@@ -1,5 +1,13 @@
<script lang="ts">
let { size }: { size?: number } = $$props;
if (!size) {
size = 20;
}
</script>
<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"
fill="none"
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">
import { derived, writable, type Writable } from "svelte/store";
import { WebsocketConnectionState, ws } from "../stores/websocketStore";
import { WebsocketConnectionState, ws } from "$stores/websocketStore";
import {
isRTCConnected,
dataChannelReady,
peer,
keyExchangeDone,
} from "../utils/webrtcUtil";
} from "$lib/webrtcUtil";
import {
advertisedOffers,
fileRequestIds,
messages,
receivedOffers,
} from "../stores/messageStore";
import { WebRTCPacketType } from "../types/webrtc";
import { RoomConnectionState, type Room } from "../types/websocket";
import { MessageType } from "../types/message";
} from "$stores/messageStore";
import { WebRTCPacketType } from "$types/webrtc";
import { RoomConnectionState, type Room } from "$types/websocket";
import { MessageType } from "$types/message";
import { fade } from "svelte/transition";
import { WebBuffer } from "../utils/buffer";
import { WebBuffer } from "../lib/buffer";
let inputMessage: Writable<string> = writable("");
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 { Socket, WebSocketMessageType, type WebSocketMessage } from "../src/types/websocket";
import { LiveMap } from '../src/utils/liveMap.ts';
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']
enum ErrorCode {
ROOM_NOT_FOUND,
}
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",
@@ -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
// then when this client disconnects, the room should be deleted since the room is empty
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(() => {
if (rooms.get(roomId)?.length === 1) {
console.log("Room is empty, deleting");
deleteRoom(roomId);
}
}, 5000)
}, 60000)
return;
}
@@ -132,6 +131,39 @@ async function joinRoom(roomId: string, socket: Socket, initial?: boolean): Prom
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);
@@ -189,10 +221,22 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
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) {
@@ -212,8 +256,13 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
}
break;
case WebSocketMessageType.JOIN_ROOM:
if (!message.roomId) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE });
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;
}
@@ -237,9 +286,27 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
return;
}
room = await leaveRoom(message.roomId, socket);
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:

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';

View File

@@ -1,11 +1,11 @@
import { writable, get, type Writable } from "svelte/store";
import { WebRTCPeer } from "$lib/webrtc";
import { CHUNK_SIZE, 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 { 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";
@@ -186,8 +186,10 @@ const callbacks = {
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();
downloadWriter = downloadStream!.getWriter();
}
await downloadWriter!.write(new Uint8Array(messageData.read()));
@@ -249,6 +251,7 @@ const callbacks = {
},
};
export async function handleMessage(event: MessageEvent) {
console.log("Message received:", event.data, typeof event.data);
const message: WebSocketMessage = JSON.parse(event.data);
@@ -299,7 +302,7 @@ export async function handleMessage(event: MessageEvent) {
}
if (!get(peer)) {
console.error("Unknown message type:", message.type);
console.debug("Unhandled message type:", message.type);
return;
}
@@ -322,7 +325,7 @@ export async function handleMessage(event: MessageEvent) {
await get(peer)?.addIceCandidate(message.data.candidate);
return;
default:
console.warn(
console.debug(
`Unknown message type: ${message.type} from ${get(room).id}`,
);
}

View File

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

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { ws } from "../stores/websocketStore";
import { WebSocketMessageType } from "../types/websocket";
import { WebsocketConnectionState, ws } from "$stores/websocketStore";
import { WebSocketMessageType } from "$types/websocket";
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 roomLoading: Writable<boolean> = writable(false);
@@ -11,12 +12,20 @@
roomLoading.set(true);
let roomId = $roomName.trim() === "" ? undefined : $roomName.trim();
ws.send({
type: WebSocketMessageType.CREATE_ROOM,
roomName: roomId,
doChallenge().then(async (challengeResult) => {
if (!challengeResult) {
return;
}
ws.send({
type: WebSocketMessageType.CREATE_ROOM,
roomName: roomId,
nonce: challengeResult.nonce,
challenge: challengeResult.challenge,
});
console.log("Created room:", roomId);
});
// todo: redirect to the room
console.log("Created room:", roomId);
}
let showRoomNameInput: Writable<boolean> = writable(false);
@@ -36,9 +45,15 @@
<form class="flex flex-col gap-5" id="roomForm">
<button
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"
><LoadingSpinner /> Creating Room...</span
>
@@ -232,7 +247,7 @@
</div>
</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">
<p>
&copy; {new Date().getFullYear()} Noctis - MIT License

View File

@@ -1,19 +1,23 @@
<script lang="ts">
import { onMount } from "svelte";
import { room } from "../../stores/roomStore";
import { ws } from "../../stores/websocketStore";
import { WebSocketMessageType } from "../../types/websocket";
import { dataChannelReady, error } from "../../utils/webrtcUtil";
import { room } from "$stores/roomStore";
import { WebsocketConnectionState, ws } from "$stores/websocketStore";
import { WebSocketMessageType } from "$types/websocket";
import { dataChannelReady, error } from "$lib/webrtcUtil";
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 roomLink = "";
let copyButtonText = "Copy Link";
export let data: { roomId: string };
const { roomId } = data;
let awaitingJoinConfirmation = $derived(!isHost);
let roomLink = $state("");
let copyButtonText = $state("Copy Link");
onMount(() => {
error.set(null);
@@ -30,12 +34,23 @@
});
}
function handleConfirmJoin() {
async function handleConfirmJoin() {
awaitingJoinConfirmation = false;
if (!roomId) {
return;
}
let challengeResult = await doChallenge(roomId);
if (!challengeResult) {
return;
}
ws.send({
type: WebSocketMessageType.JOIN_ROOM,
roomId: roomId,
roomId: roomId!,
nonce: challengeResult.nonce,
challenge: challengeResult.challenge,
});
}
@@ -56,6 +71,42 @@
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>
<div class="max-w-6xl px-5 mx-auto flex flex-col items-center">
@@ -97,23 +148,41 @@
<RtcMessage {room} />
{/if}
{:else if awaitingJoinConfirmation}
<h2 class="text-3xl font-bold text-white mb-2">
You're invited to chat.
</h2>
<div class="flex flex-row gap-2">
<button
onclick={handleConfirmJoin}
class="bg-accent hover:bg-accent/80 active:bg-accent/60 cursor-pointer text-gray-900 font-bold py-2 px-4 rounded-md transition-colors whitespace-nowrap"
>
Accept
</button>
<button
onclick={handleDeclineJoin}
class="bg-red-400 hover:bg-red-400/80 active:bg-red-400/60 cursor-pointer text-gray-900 font-bold py-2 px-4 rounded-md transition-colors whitespace-nowrap"
>
Decline
</button>
</div>
{#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">
You're invited to chat.
</h2>
<div class="flex flex-row gap-2">
<button
onclick={handleConfirmJoin}
class="bg-accent hover:bg-accent/80 active:bg-accent/60 cursor-pointer text-gray-900 font-bold py-2 px-4 rounded-md transition-colors whitespace-nowrap"
>
Accept
</button>
<button
onclick={handleDeclineJoin}
class="bg-red-400 hover:bg-red-400/80 active:bg-red-400/60 cursor-pointer text-gray-900 font-bold py-2 px-4 rounded-md transition-colors whitespace-nowrap"
>
Decline
</button>
</div>
{/if}
{:else}
<RtcMessage {room} />
{/if}

View File

@@ -1,5 +1,5 @@
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 advertisedOffers = writable(new Map<bigint, File>());

View File

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

View File

@@ -1,7 +1,7 @@
import { get, writable, type Readable, type Writable } from 'svelte/store';
import { browser } from '$app/environment';
import { Socket, type WebSocketMessage } from '../types/websocket';
import { handleMessage } from '../utils/webrtcUtil';
import { Socket, WebSocketMessageType, type WebSocketMessage } from '$types/websocket';
import { handleMessage } from '../lib/webrtcUtil';
export enum WebsocketConnectionState {
DISCONNECTED,
@@ -21,6 +21,7 @@ interface WebSocketStore extends Readable<WebSocketStoreValue> {
connect: () => void;
disconnect: () => 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)
@@ -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 {
subscribe,
connect,
disconnect,
send,
handleEvent,
};
}

View File

@@ -16,12 +16,16 @@ export enum WebSocketMessageType {
CREATE_ROOM = "create",
JOIN_ROOM = "join",
LEAVE_ROOM = "leave",
CHECK_ROOM_EXISTS = "check",
REQUEST_CHALLENGE = "request-challenge",
// response messages
ROOM_CREATED = "created",
ROOM_JOINED = "joined",
ROOM_LEFT = "left",
ROOM_READY = "ready",
ROOM_STATUS = "status",
CHALLENGE = "challenge",
// webrtc messages
WEBRTC_OFFER = "offer",
@@ -31,14 +35,19 @@ export enum WebSocketMessageType {
ERROR = "error",
}
// TODO: name the interfaces better
export type WebSocketMessage =
| CreateRoomMessage
| JoinRoomMessage
| LeaveRoomMessage
| CheckRoomExistsMessage
| RequestChallengeMessage
| RoomCreatedMessage
| RoomJoinedMessage
| RoomLeftMessage
| RoomStatusMessage
| RoomReadyMessage
| ChallengeMessage
| OfferMessage
| AnswerMessage
| IceCandidateMessage
@@ -49,14 +58,20 @@ interface ErrorMessage {
data: string;
}
// ====== Query Messages ======
interface CreateRoomMessage {
type: WebSocketMessageType.CREATE_ROOM;
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 {
type: WebSocketMessageType.JOIN_ROOM;
roomId: string;
nonce?: string;
challenge?: string;
}
interface LeaveRoomMessage {
@@ -64,6 +79,20 @@ interface LeaveRoomMessage {
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 {
type: WebSocketMessageType.ROOM_CREATED;
data: string;
@@ -80,6 +109,12 @@ interface RoomLeftMessage {
roomId: string;
}
interface RoomStatusMessage {
type: WebSocketMessageType.ROOM_STATUS;
roomId: string;
status: 'found' | 'not-found';
}
interface RoomReadyMessage {
type: WebSocketMessageType.ROOM_READY;
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 {
type: WebSocketMessageType.WEBRTC_OFFER;
data: {
@@ -122,6 +165,9 @@ export class Socket {
public addEventListener: typeof WebSocket.prototype.addEventListener;
public removeEventListener: typeof WebSocket.prototype.removeEventListener;
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) {
this.ws = webSocket;
@@ -130,6 +176,18 @@ export class Socket {
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.removeEventListener = this.ws.removeEventListener.bind(this.ws);
@@ -145,4 +203,15 @@ export class Socket {
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 { confgiureWebsocketServer } from '../server/websocketHandler.ts';
import { confgiureWebsocketServer } from './lib/server/websocketHandler.ts';
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.
// 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.
adapter: adapter()
adapter: adapter(),
alias: {
$stores: './src/stores',
$components: './src/components',
$types: './src/types',
'$lib/server': './src/lib/server',
}
},
compilerOptions: {
experimental: {