improve websocket typing

This commit is contained in:
Zoe
2025-09-16 15:50:12 +00:00
parent cad5d6d98e
commit 80b83a8e93
14 changed files with 426 additions and 367 deletions

9
.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

34
.prettierrc Normal file
View File

@@ -0,0 +1,34 @@
{
"arrowParens": "always",
"bracketSameLine": true,
"objectWrap": "preserve",
"bracketSpacing": true,
"semi": true,
"experimentalOperatorPosition": "end",
"experimentalTernaries": false,
"singleQuote": false,
"jsxSingleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "all",
"singleAttributePerLine": false,
"htmlWhitespaceSensitivity": "css",
"vueIndentScriptAndStyle": true,
"proseWrap": "preserve",
"insertPragma": false,
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte"
],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"requirePragma": false,
"tabWidth": 4,
"useTabs": false,
"embeddedLanguageFormatting": "auto"
}

View File

@@ -23,6 +23,8 @@
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/polka": "^0.5.7", "@types/polka": "^0.5.7",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
@@ -357,6 +359,10 @@
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.0", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],

View File

@@ -1,43 +1,47 @@
{ {
"name": "wormhole", "name": "wormhole",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
}, "format": "prettier --write .",
"devDependencies": { "lint": "prettier --check ."
"@sveltejs/adapter-auto": "^6.0.0", },
"@sveltejs/kit": "^2.22.0", "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/adapter-auto": "^6.0.0",
"@tailwindcss/vite": "^4.0.0", "@sveltejs/kit": "^2.22.0",
"@types/polka": "^0.5.7", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"svelte": "^5.0.0", "@tailwindcss/vite": "^4.0.0",
"svelte-check": "^4.0.0", "@types/polka": "^0.5.7",
"tailwindcss": "^4.0.0", "prettier": "^3.4.2",
"typescript": "^5.0.0", "prettier-plugin-svelte": "^3.3.3",
"vite": "^7.0.4" "svelte": "^5.0.0",
}, "svelte-check": "^4.0.0",
"dependencies": { "tailwindcss": "^4.0.0",
"@hpke/chacha20poly1305": "^1.7.1", "typescript": "^5.0.0",
"@hpke/core": "^1.7.4", "vite": "^7.0.4"
"@hpke/hybridkem-x-wing": "^0.6.1", },
"@noble/ciphers": "^1.3.0", "dependencies": {
"@noble/curves": "^1.9.0", "@hpke/chacha20poly1305": "^1.7.1",
"@sveltejs/adapter-node": "^5.3.1", "@hpke/core": "^1.7.4",
"@types/streamsaver": "^2.0.5", "@hpke/hybridkem-x-wing": "^0.6.1",
"@types/ws": "^8.18.1", "@noble/ciphers": "^1.3.0",
"polka": "^0.5.2", "@noble/curves": "^1.9.0",
"streamsaver": "^2.0.6", "@sveltejs/adapter-node": "^5.3.1",
"ts-mls": "^1.1.0", "@types/streamsaver": "^2.0.5",
"ws": "^8.18.3" "@types/ws": "^8.18.1",
}, "polka": "^0.5.2",
"trustedDependencies": [ "streamsaver": "^2.0.6",
"@tailwindcss/oxide" "ts-mls": "^1.1.0",
] "ws": "^8.18.3"
} },
"trustedDependencies": [
"@tailwindcss/oxide"
]
}

View File

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

View File

@@ -1,5 +1,5 @@
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { Socket, WebSocketMessageType, type WebSocketMessage } from "../../types/websocket.ts"; import { RoomStatusType, Socket, WebSocketErrorType, WebSocketRequestType, WebSocketResponseType, WebSocketRoomMessageType, WebSocketWebRtcMessageType, type WebSocketMessage } from "../../types/websocket.ts";
import { LiveMap } from '../liveMap.ts'; import { LiveMap } from '../liveMap.ts';
import { hashStringSHA256 } from "../powUtil.ts"; import { hashStringSHA256 } from "../powUtil.ts";
@@ -69,7 +69,7 @@ async function createRoom(socket: Socket, roomName?: string): Promise<string> {
let room = rooms.set(roomId, new ServerRoom()); let room = rooms.set(roomId, new ServerRoom());
socket.send({ type: WebSocketMessageType.ROOM_CREATED, data: room.key }); socket.send({ type: WebSocketResponseType.ROOM_CREATED, data: room.key });
try { try {
await joinRoom(room.key, socket, true); await joinRoom(room.key, socket, true);
@@ -86,21 +86,21 @@ async function joinRoom(roomId: string, socket: Socket, initial?: boolean): Prom
// should be unreachable // should be unreachable
if (!room) { if (!room) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.ROOM_NOT_FOUND });
return undefined; return undefined;
} }
if (room.length == 2) { if (room.length == 2) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_FULL }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.ROOM_FULL });
return undefined; return undefined;
} }
// notify all clients in the room of the new client, except the client itself // notify all clients in the room of the new client, except the client itself
room.notifyAll({ type: WebSocketMessageType.JOIN_ROOM, roomId }); room.notifyAll({ type: WebSocketRoomMessageType.PARTICIPANT_JOINED, roomId, participants: room.length });
room.push(socket); room.push(socket);
socket.addEventListener('close', (ev) => { socket.addEventListener('close', (ev) => {
room.notifyAll({ type: WebSocketMessageType.ROOM_LEFT, roomId }); room.notifyAll({ type: WebSocketRoomMessageType.PARTICIPANT_LEFT, roomId, participants: room.length });
// 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
@@ -118,12 +118,13 @@ async function joinRoom(roomId: string, socket: Socket, initial?: boolean): Prom
room.set(room.filter(client => client.ws !== ev.target)); room.set(room.filter(client => client.ws !== ev.target));
}); });
// sending the join message to the client who created the room is fucky
if (!initial) { if (!initial) {
socket.send({ type: WebSocketMessageType.ROOM_JOINED, roomId: roomId, participants: room.length }); socket.send({ type: WebSocketResponseType.ROOM_JOINED, roomId: roomId, participants: room.length });
} }
// TODO: consider letting rooms get larger than 2 clients // TODO: consider letting rooms get larger than 2 clients
if (room.length == 2) { if (room.length == 2) {
room.forEachClient(client => client.send({ type: WebSocketMessageType.ROOM_READY, data: { isInitiator: client !== socket } })); room.forEachClient(client => client.send({ type: WebSocketRoomMessageType.ROOM_READY, data: { isInitiator: client !== socket, roomId, participants: room.length } }));
} }
console.log("Room created:", roomId, room.length); console.log("Room created:", roomId, room.length);
@@ -148,17 +149,17 @@ function generateChallenge(): string {
return challenge; return challenge;
} }
async function validateChallenge(challenge: string, nonce: string, additionalData: string = ""): Promise<boolean> { async function validateChallenge(challenge: {target: string, nonce: string}, additionalData: string = ""): Promise<boolean> {
if (!outstandingChallenges.has(challenge)) { if (!outstandingChallenges.has(challenge.target)) {
return false; return false;
} }
let hash = await hashStringSHA256(`${additionalData}${challenge}${nonce}`); let hash = await hashStringSHA256(`${additionalData}${challenge.target}${challenge.nonce}`);
let result = hash.startsWith('0'.repeat(CHALLENGE_DIFFICULTY)); let result = hash.startsWith('0'.repeat(CHALLENGE_DIFFICULTY));
if (result) { if (result) {
console.log("Challenge solved:", challenge); console.log("Challenge solved:", challenge);
clearTimeout(outstandingChallenges.get(challenge)!); clearTimeout(outstandingChallenges.get(challenge.target)!);
outstandingChallenges.delete(challenge); outstandingChallenges.delete(challenge.target);
} }
return result; return result;
@@ -170,7 +171,7 @@ function leaveRoom(roomId: string, socket: Socket): ServerRoom | undefined {
// should be unreachable // should be unreachable
if (!room) { if (!room) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.ROOM_NOT_FOUND });
return undefined; return undefined;
} }
@@ -187,7 +188,7 @@ function leaveRoom(roomId: string, socket: Socket): ServerRoom | undefined {
room.set(room.filter(client => client !== socket)); room.set(room.filter(client => client !== socket));
socket.send({ type: WebSocketMessageType.ROOM_LEFT, roomId }); socket.send({ type: WebSocketResponseType.ROOM_LEFT, roomId });
return room; return room;
} }
@@ -217,7 +218,7 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
if (message === undefined) { if (message === undefined) {
console.log("Received non-JSON message:", event); console.log("Received non-JSON message:", event);
// If the message is not JSON, send an error message // If the message is not JSON, send an error message
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.MALFORMED_MESSAGE });
return; return;
} }
@@ -226,14 +227,14 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
let room: ServerRoom | undefined = undefined; let room: ServerRoom | undefined = undefined;
switch (message.type) { switch (message.type) {
case WebSocketMessageType.CREATE_ROOM: case WebSocketRequestType.CREATE_ROOM:
if (!message.nonce || !message.challenge) { if (!message.challenge || !message.challenge.target || !message.challenge.nonce) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MISSING_DATA }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.MISSING_DATA });
return; return;
} }
if (!await validateChallenge(message.challenge, message.nonce)) { if (!await validateChallenge(message.challenge)) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.INVALID_CHALLENGE }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.INVALID_CHALLENGE });
return; return;
} }
@@ -251,23 +252,23 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
await createRoom(socket, message.roomName); await createRoom(socket, message.roomName);
} catch (e: any) { } catch (e: any) {
socket.send({ type: WebSocketMessageType.ERROR, data: e.message }); socket.send({ type: WebSocketErrorType.ERROR, data: e.message });
throw e; throw e;
} }
break; break;
case WebSocketMessageType.JOIN_ROOM: case WebSocketRequestType.ROOM_JOIN:
if (!message.roomId || !message.nonce || !message.challenge) { if (!message.roomId || !message.challenge || !message.challenge.target || !message.challenge.nonce) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MISSING_DATA }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.MISSING_DATA });
return; return;
} }
if (!await validateChallenge(message.challenge, message.nonce, message.roomId)) { if (!await validateChallenge(message.challenge, message.roomId)) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.INVALID_CHALLENGE }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.INVALID_CHALLENGE });
return; return;
} }
if (rooms.get(message.roomId) == undefined) { if (rooms.get(message.roomId) == undefined) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.ROOM_NOT_FOUND });
return; return;
} }
@@ -275,14 +276,14 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
if (!room) return; if (!room) return;
break; break;
case WebSocketMessageType.LEAVE_ROOM: case WebSocketRequestType.ROOM_LEAVE:
if (!message.roomId) { if (!message.roomId) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.MALFORMED_MESSAGE });
return; return;
} }
if (rooms.get(message.roomId) == undefined) { if (rooms.get(message.roomId) == undefined) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.ROOM_NOT_FOUND });
return; return;
} }
@@ -290,27 +291,34 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
if (!room) return; if (!room) return;
break; break;
case WebSocketMessageType.CHECK_ROOM_EXISTS: case WebSocketRequestType.ROOM_STATUS:
if (!message.roomId || !message.nonce || !message.challenge) { if (!message.roomId || !message.challenge || !message.challenge.target || !message.challenge.nonce) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MISSING_DATA }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.MISSING_DATA });
return; return;
} }
if (!await validateChallenge(message.challenge, message.nonce, message.roomId)) { if (!await validateChallenge(message.challenge, message.roomId)) {
socket.send({ type: WebSocketMessageType.ERROR, data: errors.INVALID_CHALLENGE }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.INVALID_CHALLENGE });
return; return;
} }
socket.send({ type: WebSocketMessageType.ROOM_STATUS, roomId: message.roomId, status: rooms.get(message.roomId) ? 'found' : 'not-found' }); let roomStatus = RoomStatusType.OPEN;
break; if (!rooms.get(message.roomId)) {
case WebSocketMessageType.REQUEST_CHALLENGE: roomStatus = RoomStatusType.NOT_FOUND;
let challenge = generateChallenge(); } else if (rooms.get(message.roomId)!.length === 2) {
roomStatus = RoomStatusType.OPEN;
}
socket.send({ type: WebSocketMessageType.CHALLENGE, challenge, difficulty: CHALLENGE_DIFFICULTY }); socket.send({ type: WebSocketResponseType.ROOM_STATUS, roomId: message.roomId, status: roomStatus });
break; break;
case WebSocketMessageType.WEBRTC_OFFER: case WebSocketRequestType.CHALLENGE_REQUEST:
case WebSocketMessageType.WERTC_ANSWER: let target = generateChallenge();
case WebSocketMessageType.WEBRTC_ICE_CANDIDATE:
socket.send({ type: WebSocketResponseType.CHALLENGE_RESPONSE, target, difficulty: CHALLENGE_DIFFICULTY });
break;
case WebSocketWebRtcMessageType.OFFER:
case WebSocketWebRtcMessageType.ANSWER:
case WebSocketWebRtcMessageType.ICE_CANDIDATE:
// relay these messages to the other peers in the room // relay these messages to the other peers in the room
room = rooms.get(message.data.roomId); room = rooms.get(message.data.roomId);
@@ -324,7 +332,7 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
break; break;
default: default:
console.warn(`Unknown message type: ${message.type}`); console.warn(`Unknown message type: ${message.type}`);
socket.send({ type: WebSocketMessageType.ERROR, data: errors.UNKNOWN_MESSAGE_TYPE }); socket.send({ type: WebSocketErrorType.ERROR, data: errors.UNKNOWN_MESSAGE_TYPE });
break; break;
} }
}); });

View File

@@ -1,9 +1,9 @@
import { ws } from '$stores/websocketStore'; import { ws } from '$stores/websocketStore';
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';
import { WebSocketWebRtcMessageType } from '$types/websocket';
export class WebRTCPeer { export class WebRTCPeer {
private peer: RTCPeerConnection | null = null; private peer: RTCPeerConnection | null = null;
@@ -35,7 +35,7 @@ export class WebRTCPeer {
private sendIceCandidate(candidate: RTCIceCandidate) { private sendIceCandidate(candidate: RTCIceCandidate) {
ws.send({ ws.send({
type: WebSocketMessageType.WEBRTC_ICE_CANDIDATE, type: WebSocketWebRtcMessageType.ICE_CANDIDATE,
data: { data: {
roomId: this.roomId, roomId: this.roomId,
candidate: candidate, candidate: candidate,
@@ -261,7 +261,7 @@ export class WebRTCPeer {
await this.peer.setLocalDescription(offer) await this.peer.setLocalDescription(offer)
ws.send({ ws.send({
type: WebSocketMessageType.WEBRTC_OFFER, type: WebSocketWebRtcMessageType.OFFER,
data: { data: {
roomId: this.roomId, roomId: this.roomId,
sdp: offer, sdp: offer,
@@ -295,7 +295,7 @@ export class WebRTCPeer {
console.log("Sending answer", answer); console.log("Sending answer", answer);
ws.send({ ws.send({
type: WebSocketMessageType.WERTC_ANSWER, type: WebSocketWebRtcMessageType.ANSWER,
data: { data: {
roomId: this.roomId, roomId: this.roomId,
sdp: answer, sdp: answer,
@@ -353,7 +353,7 @@ export class WebRTCPeer {
this.send(keyPackageMessageBuf, WebRTCPacketType.KEY_PACKAGE); this.send(keyPackageMessageBuf, WebRTCPacketType.KEY_PACKAGE);
} }
public async send(data: ArrayBuffer, type: WebRTCPacketType) { public async send(data: ArrayBufferLike, type: WebRTCPacketType) {
console.log("Sending message of type", type, "with data", data); console.log("Sending message of type", type, "with data", data);
if (!this.dataChannel || this.dataChannel.readyState !== 'open') throw new Error('Data channel not initialized'); if (!this.dataChannel || this.dataChannel.readyState !== 'open') throw new Error('Data channel not initialized');

View File

@@ -2,10 +2,10 @@ import { writable, get, type Writable } from "svelte/store";
import { WebRTCPeer } from "$lib/webrtc"; import { WebRTCPeer } from "$lib/webrtc";
import { 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, WebSocketErrorType, WebSocketResponseType, WebSocketRoomMessageType, WebSocketWebRtcMessageType, 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 { type WebSocketMessage } from "$types/websocket";
import { WebBuffer } from "./buffer"; import { WebBuffer } from "./buffer";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -257,30 +257,30 @@ export async function handleMessage(event: MessageEvent) {
const message: WebSocketMessage = JSON.parse(event.data); const message: WebSocketMessage = JSON.parse(event.data);
switch (message.type) { switch (message.type) {
case WebSocketMessageType.ROOM_CREATED: case WebSocketResponseType.ROOM_CREATED:
console.log("Room created:", message.data); console.log("Room created:", message.data);
room.set({ id: message.data, host: true, RTCConnectionReady: false, connectionState: RoomConnectionState.CONNECTED, participants: 1 }); room.set({ id: message.data, host: true, RTCConnectionReady: false, connectionState: RoomConnectionState.CONNECTED, participants: 1 });
goto(`/${message.data}`); goto(`/${message.data}`);
return; return;
case WebSocketMessageType.JOIN_ROOM: case WebSocketRoomMessageType.PARTICIPANT_JOINED:
console.log("new client joined room"); console.log("new client joined room");
room.update((room) => ({ ...room, participants: room.participants + 1 })); room.update((room) => ({ ...room, participants: room.participants + 1 }));
return; return;
case WebSocketMessageType.ROOM_JOINED: case WebSocketResponseType.ROOM_JOINED:
// TODO: if a client disconnects, we need to resync the room state // 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 }); room.set({ host: false, id: message.roomId, RTCConnectionReady: false, connectionState: RoomConnectionState.CONNECTED, participants: message.participants });
console.log("Joined room"); console.log("Joined room");
return; return;
case WebSocketMessageType.ROOM_LEFT: case WebSocketRoomMessageType.PARTICIPANT_LEFT:
room.update((room) => ({ ...room, participants: room.participants - 1 })); room.update((room) => ({ ...room, participants: room.participants - 1 }));
console.log("Participant left room"); console.log("Participant left room");
return; return;
case WebSocketMessageType.ERROR: case WebSocketErrorType.ERROR:
console.error("Error:", message.data); console.error("Error:", message.data);
error.set(message.data); error.set(message.data);
return; return;
case WebSocketMessageType.ROOM_READY: case WebSocketRoomMessageType.ROOM_READY:
let roomId = get(room).id; let roomId = get(room).id;
if (roomId === null) { if (roomId === null) {
@@ -307,20 +307,20 @@ export async function handleMessage(event: MessageEvent) {
} }
switch (message.type) { switch (message.type) {
case WebSocketMessageType.WEBRTC_OFFER: case WebSocketWebRtcMessageType.OFFER:
console.log("Received offer"); console.log("Received offer");
await get(peer)?.setRemoteDescription( await get(peer)?.setRemoteDescription(
new RTCSessionDescription(message.data.sdp), new RTCSessionDescription(message.data.sdp),
); );
await get(peer)?.createAnswer(); await get(peer)?.createAnswer();
return; return;
case WebSocketMessageType.WERTC_ANSWER: case WebSocketWebRtcMessageType.ANSWER:
console.log("Received answer"); console.log("Received answer");
await get(peer)?.setRemoteDescription( await get(peer)?.setRemoteDescription(
new RTCSessionDescription(message.data.sdp), new RTCSessionDescription(message.data.sdp),
); );
return; return;
case WebSocketMessageType.WEBRTC_ICE_CANDIDATE: case WebSocketWebRtcMessageType.ICE_CANDIDATE:
console.log("Received ICE candidate"); console.log("Received ICE candidate");
await get(peer)?.addIceCandidate(message.data.candidate); await get(peer)?.addIceCandidate(message.data.candidate);
return; return;

View File

@@ -15,11 +15,7 @@
ws.subscribe((newWs) => { ws.subscribe((newWs) => {
if (newWs.status === WebsocketConnectionState.CONNECTED) { if (newWs.status === WebsocketConnectionState.CONNECTED) {
console.log( console.log("Connected to websocket server, room id:", $room.id, "reconnecting");
"Connected to websocket server, room id:",
$room.id,
"reconnecting",
);
} }
}); });
@@ -33,35 +29,29 @@
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin="anonymous" crossorigin="anonymous"
href="/fonts/InstrumentSans-VariableFont_wdth,wght.woff2" href="/fonts/InstrumentSans-VariableFont_wdth,wght.woff2" />
/>
{#if process.env.NODE_ENV !== "production"} {#if process.env.NODE_ENV !== "production"}
<!-- Debug console. Particularly useful for debugging on mobile devices -->
<script src="https://cdn.jsdelivr.net/npm/eruda"></script> <script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script> <script>
eruda.init(); eruda.init();
</script> </script>
{/if} {/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> <script src="https://cdn.jsdelivr.net/npm/streamsaver@2.0.3/StreamSaver.min.js"></script>
<script
src="https://cdn.jsdelivr.net/npm/streamsaver@2.0.3/StreamSaver.min.js"
></script>
</svelte:head> </svelte:head>
<header class="p-5"> <header class="p-5">
<div class="flex justify-between items-center max-w-7xl px-5 mx-auto"> <div class="flex justify-between items-center max-w-7xl px-5 mx-auto">
<div class="text-2xl font-bold text-white"> <div class="text-2xl font-bold text-white">
<a href="/" class="!text-white !no-underline" <a href="/" class="!text-white !no-underline">Noctis<span class="text-accent">.</span>
>Noctis<span class="text-accent">.</span></a </a>
>
</div> </div>
<nav> <nav>
<a <a href="https://github.com/juls0730/noctis" target="_blank" rel="noopener noreferrer">
href="https://github.com/juls0730/noctis" GitHub
target="_blank" </a>
rel="noopener noreferrer">GitHub</a
>
</nav> </nav>
</div> </div>
</header> </header>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { WebsocketConnectionState, ws } from "$stores/websocketStore"; import { WebsocketConnectionState, ws } from "$stores/websocketStore";
import { WebSocketMessageType } from "$types/websocket"; import { WebSocketRequestType } 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"; import { doChallenge } from "$lib/challenge";
@@ -18,10 +18,12 @@
} }
ws.send({ ws.send({
type: WebSocketMessageType.CREATE_ROOM, type: WebSocketRequestType.CREATE_ROOM,
roomName: roomId, roomName: roomId,
nonce: challengeResult.nonce, challenge: {
challenge: challengeResult.challenge, target: challengeResult.target,
nonce: challengeResult.nonce,
}
}); });
console.log("Created room:", roomId); console.log("Created room:", roomId);
@@ -35,72 +37,63 @@
<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">
<h1 class="font-bold">Your Private, Peer-to-Peer Chat Room</h1> <h1 class="font-bold">Your Private, Peer-to-Peer Chat Room</h1>
<p class="max-w-xl mx-8"> <p class="max-w-xl mx-8">
End-to-end encrypted. Peer-to-peer. No servers. No sign-ups. Just End-to-end encrypted. Peer-to-peer. No servers. No sign-ups. Just chat.
chat.
</p> </p>
<div <div
class="bg-surface p-10 rounded-xl max-w-xl shadow-xl border border-[#21293b] mt-10 mr-auto ml-auto w-full" class="bg-surface p-10 rounded-xl max-w-xl shadow-xl border border-[#21293b] mt-10 mr-auto ml-auto w-full">
>
<form class="flex flex-col gap-5" id="roomForm"> <form class="flex flex-col gap-5" id="roomForm">
<button <button
onclick={createRoom} onclick={createRoom}
disabled={$ws.status !== disabled={$ws.status !== WebsocketConnectionState.CONNECTED || $roomLoading}
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">
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 $ws.status !== WebsocketConnectionState.CONNECTED} {#if $ws.status !== WebsocketConnectionState.CONNECTED}
<span class="flex items-center" <span class="flex items-center">
><LoadingSpinner /> Connecting to server...</span <LoadingSpinner /> Connecting to server...
> </span>
{:else if $roomLoading} {:else if $roomLoading}
<span class="flex items-center" <span class="flex items-center">
><LoadingSpinner /> Creating Room...</span <LoadingSpinner /> Creating Room...
> </span>
{:else} {:else}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24">
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path <!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<path
fill="currentColor" fill="currentColor"
d="M12 2a5 5 0 0 1 5 5v3a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-6a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m0 12a2 2 0 0 0-1.995 1.85L10 16a2 2 0 1 0 2-2m0-10a3 3 0 0 0-3 3v3h6V7a3 3 0 0 0-3-3" d="M12 2a5 5 0 0 1 5 5v3a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-6a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m0 12a2 2 0 0 0-1.995 1.85L10 16a2 2 0 1 0 2-2m0-10a3 3 0 0 0-3 3v3h6V7a3 3 0 0 0-3-3" />
/></svg </svg>
>
Create Secure Room Create Secure Room
{/if} {/if}
</button> </button>
<div <div
class="{$showRoomNameInput class="{$showRoomNameInput
? 'max-h-32' ? 'max-h-32'
: 'max-h-0 opacity-0'} overflow-hidden transition-[max-height,_opacity] duration-700" : 'max-h-0 opacity-0'} overflow-hidden transition-[max-height,_opacity] duration-700">
>
<label <label
aria-hidden={!$showRoomNameInput} aria-hidden={!$showRoomNameInput}
for="roomNameInput" for="roomNameInput"
class="text-paragraph block text-sm font-medium mb-2 text-left" class="text-paragraph block text-sm font-medium mb-2 text-left">
>Enter a custom room name</label Enter a custom room name
> </label>
<input <input
type="text" type="text"
id="roomNameInput" id="roomNameInput"
bind:value={$roomName} bind:value={$roomName}
class="placeholder:text-paragraph-muted w-full py-3 px-4 rounded-lg border border-[#2c3444] bg-[#232b3e] text-paragraph transition-[border-color,_box-shadow] duration-300 ease-in-out focus:outline-none focus:border-accent focus:shadow-sm shadow-accent/20" class="placeholder:text-paragraph-muted w-full py-3 px-4 rounded-lg border border-[#2c3444] bg-[#232b3e] text-paragraph transition-[border-color,_box-shadow] duration-300 ease-in-out focus:outline-none focus:border-accent focus:shadow-sm shadow-accent/20"
placeholder="e.g., private-chat" placeholder="e.g., private-chat" />
/>
</div> </div>
<span <span class="text-paragraph {$showRoomNameInput ? 'hidden' : '-mt-5'}">
class="text-paragraph {$showRoomNameInput or <button
? 'hidden'
: '-mt-5'}"
>or <button
id="showCustomNameLink" id="showCustomNameLink"
class="cursor-pointer underline hover:no-underline text-accent" class="cursor-pointer underline hover:no-underline text-accent"
onclick={() => showRoomNameInput.set(true)} onclick={() => showRoomNameInput.set(true)}>
>choose a custom room name</button choose a custom room name
></span </button>
> </span>
</form> </form>
</div> </div>
</div> </div>
@@ -112,39 +105,35 @@
<div class="mt-10 flex justify-around gap-8 flex-wrap"> <div class="mt-10 flex justify-around gap-8 flex-wrap">
<div class="text-center max-w-3xs"> <div class="text-center max-w-3xs">
<div <div
class="text-2xl font-bold w-12 h-12 leading-12 rounded-full bg-primary text-accent mx-auto mb-5" class="text-2xl font-bold w-12 h-12 leading-12 rounded-full bg-primary text-accent mx-auto mb-5">
>
1 1
</div> </div>
<h3>Create a Room</h3> <h3>Create a Room</h3>
<p> <p>
Click the button above to create a random room instantly, no Click the button above to create a random room instantly, no personal info
personal info required. required.
</p> </p>
</div> </div>
<div class="text-center max-w-3xs"> <div class="text-center max-w-3xs">
<div <div
class="text-2xl font-bold w-12 h-12 leading-12 rounded-full bg-primary text-accent mx-auto mb-5" class="text-2xl font-bold w-12 h-12 leading-12 rounded-full bg-primary text-accent mx-auto mb-5">
>
2 2
</div> </div>
<h3>Share the Link</h3> <h3>Share the Link</h3>
<p> <p>
You'll get a unique link to your private room. Share this You'll get a unique link to your private room. Share this link with anyone you
link with anyone you want to chat with securely. want to chat with securely.
</p> </p>
</div> </div>
<div class="text-center max-w-3xs"> <div class="text-center max-w-3xs">
<div <div
class="text-2xl font-bold w-12 h-12 leading-12 rounded-full bg-primary text-accent mx-auto mb-5" class="text-2xl font-bold w-12 h-12 leading-12 rounded-full bg-primary text-accent mx-auto mb-5">
>
3 3
</div> </div>
<h3>Chat Privately</h3> <h3>Chat Privately</h3>
<p> <p>
Once they join, your messages are sent directly between your Once they join, your messages are sent directly between your devices, encrypted
devices, encrypted from end to end. Hidden from everyone from end to end. Hidden from everyone else.
else.
</p> </p>
</div> </div>
</div> </div>
@@ -154,93 +143,80 @@
<section class="py-20"> <section class="py-20">
<div class="max-w-6xl px-10 mx-auto"> <div class="max-w-6xl px-10 mx-auto">
<h2 class="font-semibold">Security by Design</h2> <h2 class="font-semibold">Security by Design</h2>
<div <div class="mt-10 grid grid-cols-[repeat(auto-fit,_minmax(300px,_1fr))] gap-8">
class="mt-10 grid grid-cols-[repeat(auto-fit,_minmax(300px,_1fr))] gap-8" <div class="bg-surface p-8 rounded-xl border border-[#21293b] text-center">
>
<div
class="bg-surface p-8 rounded-xl border border-[#21293b] text-center"
>
<div <div
class="mb-5 bg-accent/10 w-16 h-16 rounded-full inline-flex justify-center items-center text-paragraph" class="mb-5 bg-accent/10 w-16 h-16 rounded-full inline-flex justify-center items-center text-paragraph">
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24">
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><g <!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<g
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2">
><path <path
d="M5 13a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2z" d="M5 13a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2z" />
/><path <path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0-2 0m-3-5V7a4 4 0 1 1 8 0v4" />
d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0-2 0m-3-5V7a4 4 0 1 1 8 0v4" </g>
/></g </svg>
></svg
>
</div> </div>
<h3 class="font-bold">End-to-End Encrypted</h3> <h3 class="font-bold">End-to-End Encrypted</h3>
<p> <p>
Only you and the people in your room can read the messages. Only you and the people in your room can read the messages. Your data is
Your data is encrypted before its sent using the Message encrypted before its sent using the Message Layer Security (MLS) protocol.
Layer Security (MLS) protocol.
</p> </p>
</div> </div>
<div <div class="bg-surface p-8 rounded-xl border border-[#21293b] text-center">
class="bg-surface p-8 rounded-xl border border-[#21293b] text-center"
>
<div <div
class="mb-5 bg-accent/10 w-16 h-16 rounded-full inline-flex justify-center items-center text-paragraph" class="mb-5 bg-accent/10 w-16 h-16 rounded-full inline-flex justify-center items-center text-paragraph">
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24">
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path <!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<path
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M21 11V8a2 2 0 0 0-2-2h-6m0 0l3 3m-3-3l3-3M3 13.013v3a2 2 0 0 0 2 2h6m0 0l-3-3m3 3l-3 3m8-4.511a2 2 0 1 0 4.001-.001a2 2 0 0 0-4.001.001m-12-12a2 2 0 1 0 4.001-.001A2 2 0 0 0 4 4.502m17 16.997a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2m-6-12a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2" d="M21 11V8a2 2 0 0 0-2-2h-6m0 0l3 3m-3-3l3-3M3 13.013v3a2 2 0 0 0 2 2h6m0 0l-3-3m3 3l-3 3m8-4.511a2 2 0 1 0 4.001-.001a2 2 0 0 0-4.001.001m-12-12a2 2 0 1 0 4.001-.001A2 2 0 0 0 4 4.502m17 16.997a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2m-6-12a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2" />
/></svg </svg>
>
</div> </div>
<h3 class="font-bold">Truly Peer-to-Peer</h3> <h3 class="font-bold">Truly Peer-to-Peer</h3>
<p> <p>
Your messages are sent directly from your device to the Your messages are sent directly from your device to the recipient's. They never
recipient's. They never pass through a central server. pass through a central server.
</p> </p>
</div> </div>
<div <div class="bg-surface p-8 rounded-xl border border-[#21293b] text-center">
class="bg-surface p-8 rounded-xl border border-[#21293b] text-center"
>
<div <div
class="mb-5 bg-accent/10 w-16 h-16 rounded-full inline-flex justify-center items-center text-paragraph" class="mb-5 bg-accent/10 w-16 h-16 rounded-full inline-flex justify-center items-center text-paragraph">
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24">
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path <!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<path
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="m3 3l18 18M7 3h7l5 5v7m0 4a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5" d="m3 3l18 18M7 3h7l5 5v7m0 4a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5" />
/></svg </svg>
>
</div> </div>
<h3 class="font-bold">No Data Stored</h3> <h3 class="font-bold">No Data Stored</h3>
<p> <p>
We don't have accounts, and we don't store your messages. We don't have accounts, and we don't store your messages. Once you close the
Once you close the tab, the conversation is gone forever. tab, the conversation is gone forever.
</p> </p>
</div> </div>
</div> </div>
@@ -253,19 +229,19 @@
&copy; {new Date().getFullYear()} Noctis - MIT License &copy; {new Date().getFullYear()} Noctis - MIT License
<br /> <br />
Made with Made with
<span class="text-accent" <span class="text-accent">
><svg <svg
class="inline-block" class="inline-block"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
height="16" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24">
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path <!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<path
fill="currentColor" fill="currentColor"
d="M6.979 3.074a6 6 0 0 1 4.988 1.425l.037.033l.034-.03a6 6 0 0 1 4.733-1.44l.246.036a6 6 0 0 1 3.364 10.008l-.18.185l-.048.041l-7.45 7.379a1 1 0 0 1-1.313.082l-.094-.082l-7.493-7.422A6 6 0 0 1 6.979 3.074" d="M6.979 3.074a6 6 0 0 1 4.988 1.425l.037.033l.034-.03a6 6 0 0 1 4.733-1.44l.246.036a6 6 0 0 1 3.364 10.008l-.18.185l-.048.041l-7.45 7.379a1 1 0 0 1-1.313.082l-.094-.082l-7.493-7.422A6 6 0 0 1 6.979 3.074" />
/></svg </svg>
></span </span>
>
by by
<a href="https://zoeissleeping.com">zoeissleeping</a> <a href="https://zoeissleeping.com">zoeissleeping</a>
</p> </p>

View File

@@ -2,7 +2,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { room } from "$stores/roomStore"; import { room } from "$stores/roomStore";
import { WebsocketConnectionState, ws } from "$stores/websocketStore"; import { WebsocketConnectionState, ws } from "$stores/websocketStore";
import { WebSocketMessageType } from "$types/websocket"; import { RoomStatusType, WebSocketRequestType, WebSocketResponseType } from "$types/websocket";
import { dataChannelReady, error } from "$lib/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";
@@ -47,10 +47,12 @@
} }
ws.send({ ws.send({
type: WebSocketMessageType.JOIN_ROOM, type: WebSocketRequestType.ROOM_JOIN,
roomId: roomId!, roomId: roomId!,
nonce: challengeResult.nonce, challenge: {
challenge: challengeResult.challenge, target: challengeResult.target,
nonce: challengeResult.nonce,
}
}); });
} }
@@ -62,11 +64,7 @@
} }
function handleLeave() { function handleLeave() {
if ( if (confirm("Are you sure you want to leave? The chat history will be deleted.")) {
confirm(
"Are you sure you want to leave? The chat history will be deleted.",
)
) {
// In a real app, this would disconnect the P2P session and redirect. // In a real app, this would disconnect the P2P session and redirect.
window.location.href = "/"; window.location.href = "/";
} }
@@ -85,24 +83,23 @@
let challengeResult = await doChallenge(roomId); let challengeResult = await doChallenge(roomId);
if (challengeResult) { if (challengeResult) {
let unsubscribe = ws.handleEvent( let unsubscribe = ws.handleEvent(WebSocketResponseType.ROOM_STATUS, (value) => {
WebSocketMessageType.ROOM_STATUS, if (value.status === RoomStatusType.OPEN) {
(value) => { unsubscribe();
if (value.status === "found") { roomExists = true;
unsubscribe(); } else if (value.status === RoomStatusType.NOT_FOUND) {
roomExists = true; unsubscribe();
} else if (value.status === "not-found") { roomExists = false;
unsubscribe(); }
roomExists = false; });
}
},
);
ws.send({ ws.send({
type: WebSocketMessageType.CHECK_ROOM_EXISTS, type: WebSocketRequestType.ROOM_STATUS,
roomId: roomId, roomId: roomId,
nonce: challengeResult.nonce, challenge: {
challenge: challengeResult.challenge, target: challengeResult.target,
nonce: challengeResult.nonce,
}
}); });
} }
} }
@@ -115,32 +112,28 @@
Something went wrong: {$error.toLocaleLowerCase()} Something went wrong: {$error.toLocaleLowerCase()}
</h2> </h2>
<p class="!text-paragraph"> <p class="!text-paragraph">
click <a href="/">here</a> to go back to the homepage click <a href="/">here</a>
to go back to the homepage
</p> </p>
{/if} {/if}
{#if !$error} {#if !$error}
{#if isHost} {#if isHost}
{#if !$room.RTCConnectionReady} {#if !$room.RTCConnectionReady}
<h2 class="text-3xl font-bold text-white mb-2"> <h2 class="text-3xl font-bold text-white mb-2">Your secure room is ready.</h2>
Your secure room is ready.
</h2>
<p class="text-gray-400 mb-6 text-center"> <p class="text-gray-400 mb-6 text-center">
Share the link below to invite someone to chat directly with Share the link below to invite someone to chat directly with you. Once they
you. Once they join, you will be connected automatically. join, you will be connected automatically.
</p> </p>
<div <div
class="bg-gray-900 rounded-lg p-4 flex items-center justify-between gap-4 border border-gray-600" class="bg-gray-900 rounded-lg p-4 flex items-center justify-between gap-4 border border-gray-600">
> <span class="text-accent font-mono text-sm overflow-x-auto whitespace-nowrap">
<span {roomLink}
class="text-accent font-mono text-sm overflow-x-auto whitespace-nowrap" </span>
>{roomLink}</span
>
<button <button
onclick={handleCopyLink} onclick={handleCopyLink}
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" 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">
>
{copyButtonText} {copyButtonText}
</button> </button>
</div> </div>
@@ -150,35 +143,31 @@
{:else if awaitingJoinConfirmation} {:else if awaitingJoinConfirmation}
{#if $ws.status !== WebsocketConnectionState.CONNECTED || roomExists === undefined} {#if $ws.status !== WebsocketConnectionState.CONNECTED || roomExists === undefined}
<h2 class="text-3xl font-bold text-white mb-2"> <h2 class="text-3xl font-bold text-white mb-2">
<span class="flex items-center" <span class="flex items-center">
><LoadingSpinner size="24" /> Connecting to server...</span <LoadingSpinner size="24" /> Connecting to server...
> </span>
</h2> </h2>
<p class="!text-paragraph"> <p class="!text-paragraph">
click <a href="/">here</a> to go back to the homepage click <a href="/">here</a>
to go back to the homepage
</p> </p>
{:else if roomExists === false} {:else if roomExists === false}
<h2 class="text-3xl font-bold text-white mb-2"> <h2 class="text-3xl font-bold text-white mb-2">That room does not exist.</h2>
That room does not exist.
</h2>
<p class="!text-paragraph"> <p class="!text-paragraph">
click <a href="/">here</a> to go back to the homepage click <a href="/">here</a>
to go back to the homepage
</p> </p>
{:else} {: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.</h2>
You're invited to chat.
</h2>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<button <button
onclick={handleConfirmJoin} 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" 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 Accept
</button> </button>
<button <button
onclick={handleDeclineJoin} 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" 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 Decline
</button> </button>
</div> </div>

View File

@@ -1,6 +1,6 @@
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, WebSocketMessageType, type WebSocketMessage } from '$types/websocket'; import { Socket, type WebSocketMessage, type WebSocketMessageType } from '$types/websocket';
import { handleMessage } from '../lib/webrtcUtil'; import { handleMessage } from '../lib/webrtcUtil';
export enum WebsocketConnectionState { export enum WebsocketConnectionState {

View File

@@ -11,143 +11,187 @@ export interface Room {
connectionState: RoomConnectionState; connectionState: RoomConnectionState;
} }
export enum WebSocketMessageType { export interface Challenge {
// room messages // the answer is the sha256(additionalData + target + nonce)
CREATE_ROOM = "create", target: string;
JOIN_ROOM = "join", nonce: string;
LEAVE_ROOM = "leave", }
CHECK_ROOM_EXISTS = "check",
REQUEST_CHALLENGE = "request-challenge",
// response messages export enum WebSocketRequestType {
ROOM_CREATED = "created", CREATE_ROOM = "create-room",
ROOM_JOINED = "joined", ROOM_JOIN = "join-room",
ROOM_LEFT = "left", ROOM_LEAVE = "leave-room",
ROOM_READY = "ready", ROOM_STATUS = "get-room-status",
ROOM_STATUS = "status", CHALLENGE_REQUEST = "get-challenge",
CHALLENGE = "challenge", }
// webrtc messages export enum WebSocketResponseType {
WEBRTC_OFFER = "offer", ROOM_CREATED = "room-created",
WERTC_ANSWER = "answer", ROOM_JOINED = "room-joined",
WEBRTC_ICE_CANDIDATE = "ice-candidate", ROOM_LEFT = "room-left",
ROOM_STATUS = "room-status",
CHALLENGE_RESPONSE = "challenge",
}
// messages sent to room participants providing information about the room
export enum WebSocketRoomMessageType {
PARTICIPANT_LEFT = "peer-left",
ROOM_READY = "room-ready",
PARTICIPANT_JOINED = "peer-joined",
}
export enum WebSocketWebRtcMessageType {
OFFER = "offer",
ANSWER = "answer",
ICE_CANDIDATE = "ice-candidate",
}
export enum WebSocketErrorType {
ERROR = "error", ERROR = "error",
} }
export type WebSocketMessageType = WebSocketRequestType | WebSocketResponseType | WebSocketRoomMessageType | WebSocketWebRtcMessageType | WebSocketErrorType;
// TODO: name the interfaces better // TODO: name the interfaces better
export type WebSocketMessage = export type WebSocketMessage =
| CreateRoomMessage // request messages
| JoinRoomMessage | CreateRoomRequest
| LeaveRoomMessage | JoinRoomRequest
| CheckRoomExistsMessage | LeaveRoomRequest
| RequestChallengeMessage | RoomStatusRequest
| RoomCreatedMessage | ChallengeRequest
| RoomJoinedMessage // response messages
| RoomLeftMessage | RoomCreatedResponse
| RoomStatusMessage | RoomJoinedResponse
| RoomLeftResponse
| RoomStatusResponse
| ChallengeResponse
// room messages
| ParticipantJoinedMessage
| ParticipantLeftMessage
| RoomReadyMessage | RoomReadyMessage
| ChallengeMessage // webrtc messages
| OfferMessage | WebRTCOfferMessage
| AnswerMessage | WebRTCAnswerMessage
| IceCandidateMessage | WebRTCIceCandidateMessage
| ErrorMessage; // errors
| Error;
interface ErrorMessage { interface Error {
type: WebSocketMessageType.ERROR; type: WebSocketErrorType.ERROR;
data: string; data: string;
} }
// ====== Query Messages ====== // ====== Query Messages ======
interface CreateRoomMessage { interface CreateRoomRequest {
type: WebSocketMessageType.CREATE_ROOM; type: WebSocketRequestType.CREATE_ROOM;
roomName?: string; roomName?: string;
nonce: string; challenge: Challenge;
challenge: string;
} }
// TODO: this is used as a query message, but it's also used as a response message interface JoinRoomRequest {
interface JoinRoomMessage { type: WebSocketRequestType.ROOM_JOIN;
type: WebSocketMessageType.JOIN_ROOM;
roomId: string; roomId: string;
nonce?: string; challenge: Challenge;
challenge?: string;
} }
interface LeaveRoomMessage { interface LeaveRoomRequest {
type: WebSocketMessageType.LEAVE_ROOM; type: WebSocketRequestType.ROOM_LEAVE;
roomId: string; roomId: string;
} }
interface CheckRoomExistsMessage { interface RoomStatusRequest {
type: WebSocketMessageType.CHECK_ROOM_EXISTS; type: WebSocketRequestType.ROOM_STATUS;
// if sha256(roomId + challenge + nonce) has a certain number of leading zeros, then we can give the status to the user
roomId: string; roomId: string;
nonce: string; challenge: Challenge;
challenge: string;
} }
interface RequestChallengeMessage { interface ChallengeRequest {
type: WebSocketMessageType.REQUEST_CHALLENGE; type: WebSocketRequestType.CHALLENGE_REQUEST;
} }
// ====== Response Messages ====== // ====== Response Messages ======
interface RoomCreatedResponse {
interface RoomCreatedMessage { type: WebSocketResponseType.ROOM_CREATED;
type: WebSocketMessageType.ROOM_CREATED;
data: string; data: string;
} }
interface RoomJoinedMessage { interface RoomJoinedResponse {
type: WebSocketMessageType.ROOM_JOINED; type: WebSocketResponseType.ROOM_JOINED;
roomId: string; roomId: string;
participants: number; participants: number;
} }
interface RoomLeftMessage { interface RoomLeftResponse {
type: WebSocketMessageType.ROOM_LEFT; type: WebSocketResponseType.ROOM_LEFT;
roomId: string; roomId: string;
} }
interface RoomStatusMessage { interface RoomStatusRequest {
type: WebSocketMessageType.ROOM_STATUS; type: WebSocketRequestType.ROOM_STATUS;
roomId: string; roomId: string;
status: 'found' | 'not-found'; }
export enum RoomStatusType {
OPEN = "open",
FULL = "full",
NOT_FOUND = "not-found",
}
interface RoomStatusResponse {
type: WebSocketResponseType.ROOM_STATUS;
roomId: string;
status: RoomStatusType;
}
interface ChallengeResponse {
type: WebSocketResponseType.CHALLENGE_RESPONSE;
target: string;
difficulty: number;
}
// ====== Room messages ======
interface ParticipantJoinedMessage {
type: WebSocketRoomMessageType.PARTICIPANT_JOINED;
roomId: string;
participants: number;
}
interface ParticipantLeftMessage {
type: WebSocketRoomMessageType.PARTICIPANT_LEFT;
roomId: string;
participants: number;
} }
interface RoomReadyMessage { interface RoomReadyMessage {
type: WebSocketMessageType.ROOM_READY; type: WebSocketRoomMessageType.ROOM_READY;
data: { data: {
isInitiator: boolean; isInitiator: boolean;
roomId: string;
participants: number;
}; };
} }
interface ChallengeMessage {
type: WebSocketMessageType.CHALLENGE;
challenge: string;
difficulty: number;
}
// ====== WebRTC signaling messages ====== // ====== WebRTC signaling messages ======
// as the server, we dont do anything with these messages other than relay them to the other peers in the room // as the server, we dont do anything with these messages other than relay them to the other peers in the room
interface OfferMessage { interface WebRTCOfferMessage {
type: WebSocketMessageType.WEBRTC_OFFER; type: WebSocketWebRtcMessageType.OFFER;
data: { data: {
roomId: string; roomId: string;
sdp: RTCSessionDescriptionInit; sdp: RTCSessionDescriptionInit;
}; };
} }
interface AnswerMessage { interface WebRTCAnswerMessage {
type: WebSocketMessageType.WERTC_ANSWER; type: WebSocketWebRtcMessageType.ANSWER;
data: { data: {
roomId: string; roomId: string;
sdp: RTCSessionDescriptionInit; sdp: RTCSessionDescriptionInit;
}; };
} }
interface IceCandidateMessage { interface WebRTCIceCandidateMessage {
type: WebSocketMessageType.WEBRTC_ICE_CANDIDATE; type: WebSocketWebRtcMessageType.ICE_CANDIDATE;
data: { data: {
roomId: string; roomId: string;
candidate: RTCIceCandidateInit; candidate: RTCIceCandidateInit;

View File

@@ -15,7 +15,6 @@ const config = {
$stores: './src/stores', $stores: './src/stores',
$components: './src/components', $components: './src/components',
$types: './src/types', $types: './src/types',
'$lib/server': './src/lib/server',
} }
}, },
compilerOptions: { compilerOptions: {