file uploads and bug fixes

This commit is contained in:
Zoe
2025-09-14 20:48:02 -05:00
parent 7fca00698a
commit 4ddc5c526b
17 changed files with 895 additions and 142 deletions

View File

@@ -9,8 +9,11 @@
"@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/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",
"ts-mls": "^1.1.0", "ts-mls": "^1.1.0",
"ws": "^8.18.3", "ws": "^8.18.3",
}, },
@@ -242,6 +245,8 @@
"@types/serve-static": ["@types/serve-static@1.15.8", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "*" } }, "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg=="], "@types/serve-static": ["@types/serve-static@1.15.8", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "*" } }, "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg=="],
"@types/streamsaver": ["@types/streamsaver@2.0.5", "", {}, "sha512-93o0zjV8swEhR2YI57h/2ytbJF8bJh7sI9GNB02TLJHdM4fWDxZuChwfWhyD8vt2ub4kw4rsfZ0C0yAUX+3gcg=="],
"@types/trouter": ["@types/trouter@3.1.4", "", {}, "sha512-4YIL/2AvvZqKBWenjvEpxpblT2KGO6793ipr5QS7/6DpQ3O3SwZGgNGWezxf3pzeYZc24a2pJIrR/+Jxh/wYNQ=="], "@types/trouter": ["@types/trouter@3.1.4", "", {}, "sha512-4YIL/2AvvZqKBWenjvEpxpblT2KGO6793ipr5QS7/6DpQ3O3SwZGgNGWezxf3pzeYZc24a2pJIrR/+Jxh/wYNQ=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
@@ -290,6 +295,8 @@
"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=="],
@@ -368,6 +375,8 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"streamsaver": ["streamsaver@2.0.6", "", {}, "sha512-LK4e7TfCV8HzuM0PKXuVUfKyCB1FtT9L0EGxsFk5Up8njj0bXK8pJM9+Wq2Nya7/jslmCQwRK39LFm55h7NBTw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.38.8", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-UDpTbM/iuZ4MaMnn4ODB3rf5JKDyPOi5oJcopP0j7YHQ9BuJtsAqsR71r2N6AnJf7ygbalTJU5y8eSWGAQZjlQ=="], "svelte": ["svelte@5.38.8", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-UDpTbM/iuZ4MaMnn4ODB3rf5JKDyPOi5oJcopP0j7YHQ9BuJtsAqsR71r2N6AnJf7ygbalTJU5y8eSWGAQZjlQ=="],

View File

@@ -29,8 +29,11 @@
"@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/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",
"ts-mls": "^1.1.0", "ts-mls": "^1.1.0",
"ws": "^8.18.3" "ws": "^8.18.3"
}, },

View File

@@ -75,11 +75,6 @@ async function joinRoom(roomId: string, socket: Socket): Promise<ServerRoom | un
room.push(socket); room.push(socket);
socket.addEventListener('close', (ev) => { socket.addEventListener('close', (ev) => {
room = rooms.get(roomId)
if (!room) {
throw new Error("Room not found");
}
room.notifyAll({ type: WebSocketMessageType.ROOM_LEFT, roomId }); room.notifyAll({ type: WebSocketMessageType.ROOM_LEFT, roomId });
// for some reason, when you filter the array when the length is 1 it stays at 1, but we *know* that if its 1 // for some reason, when you filter the array when the length is 1 it stays at 1, but we *know* that if its 1
@@ -108,6 +103,34 @@ async function joinRoom(roomId: string, socket: Socket): Promise<ServerRoom | un
return room; return room;
} }
function leaveRoom(roomId: string, socket: Socket): ServerRoom | undefined {
let room = rooms.get(roomId);
console.log(room?.length);
// should be unreachable
if (!room) {
socket.send({ type: WebSocketMessageType.ERROR, data: `Room ${roomId} does not exist` });
return undefined;
}
if (room.length == 1) {
// give a 5 second grace period before deleting the room
setTimeout(() => {
if (rooms.get(roomId)?.length === 1) {
console.log("Room is empty, deleting");
deleteRoom(roomId);
}
}, 5000)
return;
}
room.set(room.filter(client => client !== socket));
socket.send({ type: WebSocketMessageType.ROOM_LEFT, roomId });
return room;
}
function deleteRoom(roomId: string) { function deleteRoom(roomId: string) {
rooms.delete(roomId); rooms.delete(roomId);
} }
@@ -165,6 +188,21 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
// the client is now in the room and the peer knows about it // the client is now in the room and the peer knows about it
socket.send({ type: WebSocketMessageType.ROOM_JOINED, roomId: message.roomId, participants: room.length }); socket.send({ type: WebSocketMessageType.ROOM_JOINED, roomId: message.roomId, participants: room.length });
break;
case WebSocketMessageType.LEAVE_ROOM:
if (!message.roomId) {
socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid message' });
return;
}
if (rooms.get(message.roomId) == undefined) {
socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid roomId' });
return;
}
room = await leaveRoom(message.roomId, socket);
if (!room) return;
break; break;
case WebSocketMessageType.WEBRTC_OFFER: case WebSocketMessageType.WEBRTC_OFFER:
case WebSocketMessageType.WERTC_ANSWER: case WebSocketMessageType.WERTC_ANSWER:

View File

@@ -1,11 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8" /> <head>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta charset="utf-8" />
%sveltekit.head% <meta name="viewport" content="width=device-width, initial-scale=1" />
</head> %sveltekit.head%
<body data-sveltekit-preload-data="hover"> </head>
<div style="display: contents">%sveltekit.body%</div>
</body> <body data-sveltekit-preload-data="hover">
</html> <div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -8,26 +8,55 @@
peer, peer,
keyExchangeDone, keyExchangeDone,
} from "../utils/webrtcUtil"; } from "../utils/webrtcUtil";
import { messages } from "../stores/messageStore"; import {
advertisedOffers,
fileRequestIds,
messages,
receivedOffers,
} from "../stores/messageStore";
import { WebRTCPacketType } from "../types/webrtc"; import { WebRTCPacketType } from "../types/webrtc";
import { ConnectionState, type Room } from "../types/websocket"; import { ConnectionState, 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";
let inputMessage: Writable<string> = writable(""); let inputMessage: Writable<string> = writable("");
let inputFile = writable(null); let inputFile: Writable<FileList | null | undefined> = writable(null);
let inputFileElement: HTMLInputElement | null = $state(null); let inputFileElement: HTMLInputElement | null = $state(null);
let initialConnectionCompleteCount = writable(0);
let initialConnectionComplete = derived( let initialConnectionComplete = derived(
[isRTCConnected, dataChannelReady, keyExchangeDone], initialConnectionCompleteCount,
(values: Array<boolean>) => values.every((value) => value), (value) => value === 3,
); );
// TODO: is this the most elegant way to do this?
isRTCConnected.subscribe((value) => {
if (value) {
$initialConnectionCompleteCount++;
}
});
dataChannelReady.subscribe((value) => {
if (value) {
$initialConnectionCompleteCount++;
}
});
keyExchangeDone.subscribe((value) => {
if (value) {
$initialConnectionCompleteCount++;
}
});
const { room }: { room: Writable<Room> } = $props(); const { room }: { room: Writable<Room> } = $props();
room.subscribe((newRoom) => { room.subscribe((newRoom) => {
console.log("Room changed:", newRoom); console.log("Room changed:", newRoom);
if (newRoom.id !== $room?.id) { if (newRoom.id !== $room?.id) {
messages.set([]); messages.set([]);
isRTCConnected.set(false);
dataChannelReady.set(false);
keyExchangeDone.set(false);
} }
}); });
@@ -37,15 +66,69 @@
return; return;
} }
if (!$inputFile && !$inputMessage) { let messageBuf: Uint8Array<ArrayBuffer> | undefined = undefined;
if (!$inputFile && !$inputMessage.trim()) {
return; return;
} }
// if ($inputFile != null && $inputFile[0] !== undefined) { if ($inputFile != null && $inputFile[0] !== undefined) {
// $messages = [...$messages, `You: ${$inputFile[0].name}`]; // fileSize + fileNameSize + fileNameLen + id + textLen + header
// $peer.send($inputFile[0]); let messageLen =
// $inputFile = null; 8 +
// } $inputFile[0].name.length +
2 +
8 +
$inputMessage.length +
1;
let messageBuf = new WebBuffer(new ArrayBuffer(messageLen));
let fileId = new WebBuffer(
crypto.getRandomValues(new Uint8Array(8)).buffer,
).readBigInt64LE();
$advertisedOffers.set(fileId, $inputFile[0]);
console.log(
"Advertised file:",
fileId,
$inputFile[0].size,
$inputFile[0].name,
$inputFile[0].name.length,
);
messageBuf.writeInt8(MessageType.FILE_OFFER);
messageBuf.writeBigInt64LE(BigInt($inputFile[0].size));
messageBuf.writeInt16LE($inputFile[0].name.length);
messageBuf.writeString($inputFile[0].name);
messageBuf.writeBigInt64LE(fileId);
messageBuf.writeString($inputMessage);
console.log(
"Sending file offer",
new Uint8Array(messageBuf.buffer),
);
$messages = [
...$messages,
{
initiator: true,
type: MessageType.FILE_OFFER,
data: {
fileSize: BigInt($inputFile[0].size),
fileNameSize: $inputFile[0].name.length,
fileName: $inputFile[0].name,
id: fileId,
text: $inputMessage === "" ? null : $inputMessage,
},
},
];
$inputFile = null;
$inputMessage = "";
$peer.send(messageBuf.buffer, WebRTCPacketType.MESSAGE);
return;
}
if ($inputMessage) { if ($inputMessage) {
$messages = [ $messages = [
@@ -56,17 +139,46 @@
data: $inputMessage, data: $inputMessage,
}, },
]; ];
$peer.send(
new TextEncoder().encode( let newMessageBuf = new ArrayBuffer(1 + $inputMessage.length);
JSON.stringify({ messageBuf = new Uint8Array(newMessageBuf);
type: MessageType.TEXT,
data: $inputMessage, messageBuf[0] = MessageType.TEXT;
}), messageBuf.set(new TextEncoder().encode($inputMessage), 1);
).buffer,
WebRTCPacketType.MESSAGE,
);
$inputMessage = ""; $inputMessage = "";
} }
if (!messageBuf) {
return;
}
$peer.send(messageBuf.buffer, WebRTCPacketType.MESSAGE);
}
function downloadFile(id: bigint) {
if (!$peer) {
console.error("Peer not initialized");
return;
}
let file = $receivedOffers.get(id);
if (!file) {
console.error("Unknown file id:", id);
return;
}
let requesterId = new WebBuffer(
crypto.getRandomValues(new Uint8Array(8)).buffer,
).readBigInt64LE();
let fileRequestBuf = new WebBuffer(new ArrayBuffer(1 + 8 + 8));
fileRequestBuf.writeInt8(MessageType.FILE_REQUEST);
fileRequestBuf.writeBigInt64LE(id);
fileRequestBuf.writeBigInt64LE(requesterId);
$fileRequestIds.set(requesterId, id);
$peer.send(fileRequestBuf.buffer, WebRTCPacketType.MESSAGE);
} }
let canCloseLoadingOverlay = writable(false); let canCloseLoadingOverlay = writable(false);
@@ -85,6 +197,29 @@
inputFileElement.click(); inputFileElement.click();
} }
function autogrow(node: HTMLElement) {
function resize() {
// 1. Temporarily reset height to calculate the new scrollHeight
node.style.height = "0px";
// 2. Set the height to the scrollHeight, which represents the full content height
node.style.height = `${node.scrollHeight}px`;
}
// Call resize initially in case the textarea already has content
resize();
// Add an event listener to resize on every input
node.addEventListener("input", resize);
// Return a destroy method to clean up the event listener when the component is unmounted
return {
update: resize,
destroy() {
node.removeEventListener("input", resize);
},
};
}
</script> </script>
<p> <p>
@@ -101,16 +236,16 @@
class="flex flex-col sm:max-w-4/5 lg:max-w-3/5 min-h-[calc(5/12_*_100vh)]" class="flex flex-col sm:max-w-4/5 lg:max-w-3/5 min-h-[calc(5/12_*_100vh)]"
> >
<div <div
class="flex-grow flex flex-col overflow-y-auto mb-4 p-2 bg-gray-800 rounded break-all relative" class="flex-grow flex flex-col overflow-y-auto mb-4 p-2 bg-gray-800 rounded relative whitespace-break-spaces wrap-anywhere"
> >
{#if !$initialConnectionComplete || $room.connectionState === ConnectionState.RECONNECTING || $room.participants !== 2 || !$canCloseLoadingOverlay} {#if !$initialConnectionComplete || $room.connectionState === ConnectionState.RECONNECTING || $room.participants !== 2 || $dataChannelReady === false || !$canCloseLoadingOverlay}
<div <div
transition:fade={{ duration: 300 }} transition:fade={{ duration: 300 }}
class="absolute top-0 left-0 bottom-0 right-0 flex justify-center items-center flex-col bg-black/55 backdrop-blur-md" class="absolute top-0 left-0 bottom-0 right-0 flex justify-center items-center flex-col bg-black/55 backdrop-blur-md z-10 text-center"
> >
{#if !$isRTCConnected} {#if !$isRTCConnected}
<p>Waiting for peer to connect...</p> <p>Waiting for peer to connect...</p>
{:else if !$dataChannelReady} {:else if !$dataChannelReady && !$initialConnectionComplete}
<p>Establishing data channel...</p> <p>Establishing data channel...</p>
{:else if !$keyExchangeDone} {:else if !$keyExchangeDone}
<p>Establishing a secure connection with the peer...</p> <p>Establishing a secure connection with the peer...</p>
@@ -118,7 +253,7 @@
<p> <p>
Disconnect from peer, attempting to reconnecting... Disconnect from peer, attempting to reconnecting...
</p> </p>
{:else if $room.participants !== 2} {:else if $room.participants !== 2 || $dataChannelReady === false}
<p> <p>
Peer has disconnected, waiting for other peer to Peer has disconnected, waiting for other peer to
reconnect... reconnect...
@@ -130,7 +265,7 @@
</p> </p>
{/if} {/if}
<div class="mt-2"> <div class="mt-2">
{#if !$keyExchangeDone || $room.participants !== 2 || $room.connectionState === ConnectionState.RECONNECTING} {#if !$keyExchangeDone || $room.participants !== 2 || $dataChannelReady === false || $room.connectionState === ConnectionState.RECONNECTING}
<!-- loading spinner --> <!-- loading spinner -->
<svg <svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
@@ -174,20 +309,59 @@
{/if} {/if}
{#each $messages as msg} {#each $messages as msg}
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<p class="break-keep"> <p class="whitespace-nowrap">
{#if msg.initiator} {#if msg.initiator}
You: You:
{:else} {:else}
Peer: Peer:
{/if} {/if}
</p> </p>
<p> {#if msg.type === MessageType.TEXT}
{#if msg.type === MessageType.TEXT} <p>{msg.data}</p>
{msg.data} {:else if msg.type === MessageType.FILE_OFFER}
{:else} <div class="flex flex-col w-full mb-2">
Unknown message type: {msg.type} {#if msg.data.text !== null}
{/if} <p>
</p> {msg.data.text}
</p>
{/if}
<div
class="flex flex-col p-2 relative w-8/12 bg-gray-600 rounded"
>
<h2 class="text-lg font-semibold my-1">
{msg.data.fileName}
</h2>
<p class="text-sm">
{msg.data.fileSize} bytes
</p>
<!-- as the initiator, we cant send ourselves a file -->
{#if !msg.initiator}
<button
onclick={() =>
downloadFile(msg.data.id)}
class="absolute right-2 bottom-2 p-1 border border-gray-500 text-gray-100 hover:bg-gray-800/70 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 11l5 5l5-5m-5-7v12"
/></svg
>
</button>
{/if}
</div>
</div>
{:else}
<p>Unknown message type: {msg.type}</p>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@@ -198,52 +372,143 @@
class="absolute opacity-0 -top-[9999px] -left-[9999px]" class="absolute opacity-0 -top-[9999px] -left-[9999px]"
/> />
<div class="flex gap-2 w-full flex-row"> <div class="flex gap-2 w-full flex-row">
<input <div
type="text" class="border rounded border-gray-600 flex-grow flex flex-col bg-gray-700"
bind:value={$inputMessage}
onkeyup={(e) => e.key === "Enter" && sendMessage()}
disabled={!$isRTCConnected ||
!$dataChannelReady ||
!$keyExchangeDone ||
$room.connectionState === ConnectionState.RECONNECTING}
placeholder="Type your message..."
class="flex-grow p-2 rounded bg-gray-700 border border-gray-600 text-gray-100 placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<button
onclick={pickFile}
disabled={!$isRTCConnected ||
!$dataChannelReady ||
!$keyExchangeDone ||
$room.connectionState === ConnectionState.RECONNECTING}
aria-label="Pick file"
class="px-4 py-2 bg-blue-600 not-disabled:hover:bg-blue-700 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
> >
<svg {#if $inputFile}
xmlns="http://www.w3.org/2000/svg" <div class="flex flex-row gap-2 p-2">
width="16" <div
height="16" class="p-2 flex flex-col gap-2 w-48 border rounded-md border-gray-600 relative"
viewBox="0 0 24 24" >
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path <div class="w-full flex justify-center">
fill="none" <svg
stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
stroke-linecap="round" width="128"
stroke-linejoin="round" height="128"
stroke-width="2" viewBox="0 0 24 24"
d="m15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3L18 10a3 3 0 0 0-6-6l-6.5 6.5a4.5 4.5 0 0 0 9 9L21 13" ><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><g
/></svg fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
><path
d="M14 3v4a1 1 0 0 0 1 1h4"
/><path
d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2"
/></g
></svg
>
</div>
<p
class="text-sm whitespace-nowrap overflow-hidden text-ellipsis"
>
{$inputFile[0].name}
</p>
<button
onclick={() => {
$inputFile = null;
}}
class="absolute right-2 top-2 p-1 border border-gray-600 text-gray-100 hover:bg-gray-800/70 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 6L6 18M6 6l12 12"
/></svg
>
</button>
</div>
</div>
<hr class="border-gray-600" />
{/if}
<div
class="flex flex-row focus-within:ring-2 focus-within:ring-blue-500 rounded"
> >
</button> <textarea
<button bind:value={$inputMessage}
onclick={sendMessage} cols="1"
disabled={!$isRTCConnected || use:autogrow={$inputMessage}
!$dataChannelReady || onkeydown={(e) => {
!$keyExchangeDone || if (
$room.connectionState === ConnectionState.RECONNECTING} e.key === "Enter" &&
class="px-4 py-2 bg-blue-600 not-disabled:hover:bg-blue-700 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed" !e.getModifierState("Shift")
> ) {
Send e.preventDefault();
</button> sendMessage();
}
}}
disabled={!$isRTCConnected ||
!$dataChannelReady ||
!$keyExchangeDone ||
$room.connectionState ===
ConnectionState.RECONNECTING}
placeholder="Type your message..."
class="flex-grow p-2 bg-gray-700 rounded text-gray-100 placeholder-gray-400 min-h-12
focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed resize-none leading-8"
></textarea>
<div class="flex flex-row gap-2 p-2 h-fit mt-auto">
<button
onclick={pickFile}
disabled={!$isRTCConnected ||
!$dataChannelReady ||
!$keyExchangeDone ||
$room.connectionState ===
ConnectionState.RECONNECTING}
aria-label="Pick file"
class="not-disabled:hover:bg-gray-800/70 h-fit p-1 text-gray-100 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3L18 10a3 3 0 0 0-6-6l-6.5 6.5a4.5 4.5 0 0 0 9 9L21 13"
/></svg
>
</button>
<button
onclick={sendMessage}
disabled={!$isRTCConnected ||
!$dataChannelReady ||
!$keyExchangeDone ||
$room.connectionState ===
ConnectionState.RECONNECTING}
class="not-disabled:hover:bg-gray-800/70 h-fit p-1 text-gray-100 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14L21 3m0 0l-6.5 18a.55.55 0 0 1-1 0L10 14l-7-3.5a.55.55 0 0 1 0-1z"
/></svg
>
</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -55,6 +55,10 @@ export class WebRTCPeer {
iceServers: this.iceServers, iceServers: this.iceServers,
}); });
this.peer.onicecandidateerror = (event) => {
console.error("ICE candidate error:", event);
}
// 1. Initialize ICE candidates // 1. Initialize ICE candidates
this.peer.onicecandidate = (event) => { this.peer.onicecandidate = (event) => {
if (event.candidate) { if (event.candidate) {
@@ -93,14 +97,12 @@ export class WebRTCPeer {
channel.binaryType = "arraybuffer"; channel.binaryType = "arraybuffer";
channel.onopen = async () => { channel.onopen = async () => {
console.log('data channel open'); this.callbacks.onDataChannelStateChange(true);
this.callbacks.onDataChannelOpen();
this.callbacks.onKeyExchangeDone();
await this.generateKeyPair();
try { try {
if (this.isInitiator) { if (this.isInitiator) {
await this.generateKeyPair();
let groupId = crypto.getRandomValues(new Uint8Array(24)); let groupId = crypto.getRandomValues(new Uint8Array(24));
this.clientState = await createGroup(groupId, this.keyPackage!.publicPackage, this.keyPackage!.privatePackage, [], this.cipherSuite!); this.clientState = await createGroup(groupId, this.keyPackage!.publicPackage, this.keyPackage!.privatePackage, [], this.cipherSuite!);
@@ -136,6 +138,7 @@ export class WebRTCPeer {
console.log("parsed data", data, encrypted, type); console.log("parsed data", data, encrypted, type);
if (type === WebRTCPacketType.GROUP_OPEN) { if (type === WebRTCPacketType.GROUP_OPEN) {
await this.generateKeyPair();
await this.startKeyExchange(); await this.startKeyExchange();
return; return;
} }
@@ -179,6 +182,7 @@ export class WebRTCPeer {
this.send(encodedWelcomeBuf, WebRTCPacketType.WELCOME); this.send(encodedWelcomeBuf, WebRTCPacketType.WELCOME);
this.encyptionReady = true; this.encyptionReady = true;
this.callbacks.onKeyExchangeDone();
return; return;
} }
@@ -202,6 +206,7 @@ export class WebRTCPeer {
console.log("Joined group", this.clientState); console.log("Joined group", this.clientState);
this.encyptionReady = true; this.encyptionReady = true;
this.callbacks.onKeyExchangeDone();
return; return;
} }
@@ -231,11 +236,12 @@ export class WebRTCPeer {
data: data.buffer, data: data.buffer,
}; };
this.callbacks.onMessage(message); this.callbacks.onMessage(message, this);
}; };
channel.onclose = () => { channel.onclose = () => {
console.log('data channel closed'); this.callbacks.onDataChannelStateChange(false);
}; };
channel.onerror = (error) => { channel.onerror = (error) => {

View File

@@ -7,6 +7,12 @@
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
<script
src="https://cdn.jsdelivr.net/npm/web-streams-polyfill@2.0.2/dist/ponyfill.min.js"
></script>
<script
src="https://cdn.jsdelivr.net/npm/streamsaver@2.0.3/StreamSaver.min.js"
></script>
</svelte:head> </svelte:head>
{@render children?.()} {@render children?.()}

View File

@@ -38,6 +38,12 @@
type: WebSocketMessageType.LEAVE_ROOM, type: WebSocketMessageType.LEAVE_ROOM,
roomId: $room.id, roomId: $room.id,
}); });
$peer?.close();
peer.set(null);
room.update((room) => ({
...room,
connectionState: ConnectionState.DISCONNECTED,
}));
} }
$ws.send({ type: WebSocketMessageType.CREATE_ROOM }); // send a message when the button is clicked $ws.send({ type: WebSocketMessageType.CREATE_ROOM }); // send a message when the button is clicked
}}>Create Room</button }}>Create Room</button

View File

@@ -49,7 +49,7 @@
<div class="p-4"> <div class="p-4">
{#if $error} {#if $error}
<p>Whoops! That room doesn't exist.</p> <p>Hm. Something went wrong: {$error.toLocaleLowerCase()}</p>
{:else if $room.connectionState !== ConnectionState.CONNECTED && $room.connectionState !== ConnectionState.RECONNECTING} {:else if $room.connectionState !== ConnectionState.CONNECTED && $room.connectionState !== ConnectionState.RECONNECTING}
<p>Connecting to server...</p> <p>Connecting to server...</p>
{:else} {:else}

View File

@@ -1,4 +1,8 @@
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 receivedOffers = writable(new Map<bigint, { name: string, size: bigint }>());
// maps request id to received file id
export let fileRequestIds: Writable<Map<bigint, bigint>> = writable(new Map());

View File

@@ -8,6 +8,8 @@ export const webSocketConnected = writable(false);
function createSocket(): Socket { function createSocket(): Socket {
if (!browser) { if (!browser) {
// this only occurs on the server, which we dont care about because its not a client that can actually connect to the websocket server
// @ts-ignore
return null; return null;
} }

View File

@@ -1,13 +1,15 @@
export enum MessageType { export enum MessageType {
// chat packets // chat packets
TEXT = 0, TEXT,
// user offers to send a file // user offers to send a file
FILE_OFFER = 1, FILE_OFFER,
// user downloads a file offered by the peer // user downloads a file offered by the peer
FILE_REQUEST = 2, FILE_REQUEST,
// file packets // file packets
FILE = 3, FILE,
FILE_ACK,
FILE_DONE,
ERROR = 255 ERROR = 255
} }
@@ -16,6 +18,7 @@ export type Message =
| TextMessage | TextMessage
| FileOfferMessage | FileOfferMessage
| FileRequestMessage | FileRequestMessage
| FileAckMessage
| FileMessage | FileMessage
| ErrorMessage; | ErrorMessage;
@@ -32,28 +35,51 @@ export interface TextMessage extends BaseMessage {
export interface FileOfferMessage extends BaseMessage { export interface FileOfferMessage extends BaseMessage {
type: MessageType.FILE_OFFER; type: MessageType.FILE_OFFER;
data: { data: {
// 64 bit file size. chunked at 1024 bytes
fileSize: bigint;
// 16 bit file name size
fileNameSize: number;
fileName: string; fileName: string;
fileSize: number; // 64bit randomly generated id to identify the file so that multiple files with the same name can be uploaded
// randomly generated to identify the file so that multiple files with the same name can be uploaded id: bigint;
id: string; text: string | null;
}; };
} }
export interface FileRequestMessage extends BaseMessage { export interface FileRequestMessage extends BaseMessage {
type: MessageType.FILE_REQUEST; type: MessageType.FILE_REQUEST;
data: { data: {
id: string; // 64 bit file id
id: bigint;
// 64 bit requester id
requesterId: bigint;
}; };
} }
export interface FileAckMessage extends BaseMessage {
type: MessageType.FILE_ACK;
// the request id
id: bigint;
}
// ----- file packets ----- // ----- file packets -----
export interface FileMessage extends BaseMessage { export interface FileMessage extends BaseMessage {
type: MessageType.FILE; type: MessageType.FILE;
data: { data: {
id: string; // the request id
fileName: string; id: bigint;
fileSize: number; // no file metadata is sent here, because we already know all of it from the request id
data: ArrayBuffer; // comes down in 16MB chunks
data: Blob;
};
}
export interface FileDoneMessage extends BaseMessage {
type: MessageType.FILE_DONE;
data: {
// the request id
id: bigint;
}; };
} }

View File

@@ -1,7 +1,9 @@
import type { WebRTCPeer } from "$lib/webrtc";
export interface WebRTCPeerCallbacks { export interface WebRTCPeerCallbacks {
onConnected: () => void; onConnected: () => void;
onMessage: (message: { type: WebRTCPacketType, data: ArrayBuffer }) => void; onMessage: (message: { type: WebRTCPacketType, data: ArrayBuffer }, webRtcPeer: WebRTCPeer) => void;
onDataChannelOpen: () => void; onDataChannelStateChange: (state: boolean) => void;
onKeyExchangeDone: () => void; onKeyExchangeDone: () => void;
onNegotiationNeeded: () => void; onNegotiationNeeded: () => void;
onError: (error: any) => void; onError: (error: any) => void;
@@ -17,6 +19,8 @@ export enum WebRTCPacketType {
MESSAGE = 0, MESSAGE = 0,
} }
export const CHUNK_SIZE = 16 * 1024 * 1024;
export interface WebRTCPacket { export interface WebRTCPacket {
encrypted: boolean; // 1 bit encrypted: boolean; // 1 bit
type: WebRTCPacketType; // 7 bits type: WebRTCPacketType; // 7 bits

View File

@@ -1,4 +1,3 @@
export enum ConnectionState { export enum ConnectionState {
CONNECTING, CONNECTING,
RECONNECTING, RECONNECTING,

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

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

View File

@@ -1,11 +1,12 @@
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 { WebRTCPacketType } from "../types/webrtc"; import { CHUNK_SIZE, WebRTCPacketType } from "../types/webrtc";
import { room } from "../stores/roomStore"; import { room } from "../stores/roomStore";
import { ConnectionState, type Room } from "../types/websocket"; import { ConnectionState, type Room } from "../types/websocket";
import { messages } 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";
export const error: Writable<string | null> = writable(null); export const error: Writable<string | null> = writable(null);
export let peer: Writable<WebRTCPeer | null> = writable(null); export let peer: Writable<WebRTCPeer | null> = writable(null);
@@ -13,41 +14,225 @@ export let isRTCConnected: Writable<boolean> = writable(false);
export let dataChannelReady: Writable<boolean> = writable(false); export let dataChannelReady: Writable<boolean> = writable(false);
export let keyExchangeDone: Writable<boolean> = writable(false); export let keyExchangeDone: Writable<boolean> = writable(false);
let downloadStream: WritableStream<Uint8Array> | undefined;
let downloadWriter: WritableStreamDefaultWriter<Uint8Array<ArrayBufferLike>> | undefined;
let fileAck: Map<bigint, Writable<boolean>> = new Map();
function beforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
event.returnValue = true;
}
function onPageHide(event: PageTransitionEvent) {
if (event.persisted) {
// page is frozen, but not closed
return;
}
if (downloadWriter && !downloadWriter.closed) {
downloadWriter.abort();
}
if (downloadStream) {
downloadStream.getWriter().abort();
}
downloadStream = undefined;
downloadWriter = undefined;
}
const callbacks = { const callbacks = {
onConnected: () => { onConnected: () => {
console.log("Connected to peer"); console.log("Connected to peer");
isRTCConnected.set(true); isRTCConnected.set(true);
}, },
//! TODO: come up with a more complex room system. This is largely for testing purposes //! TODO: come up with a more complex room system. This is largely for testing purposes
onMessage: (message: { type: WebRTCPacketType, data: ArrayBuffer }) => { onMessage: async (message: { type: WebRTCPacketType, data: ArrayBuffer }, webRtcPeer: WebRTCPeer) => {
console.log("WebRTC Received message:", message); console.log("WebRTC Received message:", message);
// if (typeof message === 'object' && message instanceof Blob) { if (message.type !== WebRTCPacketType.MESSAGE) {
// // download the file return;
// const url = URL.createObjectURL(message); }
// const a = document.createElement('a');
// a.href = url;
// a.download = message.name;
// document.body.appendChild(a);
// a.click();
// setTimeout(() => {
// document.body.removeChild(a);
// window.URL.revokeObjectURL(url);
// }, 100);
// }
console.log("Received message:", message); console.log("Received message:", message.type, new Uint8Array(message.data));
// TODO: fixup let messageBuf = new WebBuffer(message.data);
if (message.type === WebRTCPacketType.MESSAGE) { console.log("manually extracted type:", messageBuf[0]);
let textDecoder = new TextDecoder();
let json: Message = JSON.parse(textDecoder.decode(message.data)); let messageType = messageBuf[0] as MessageType;
json.initiator = false; let messageData = messageBuf.slice(1);
messages.set([...get(messages), json]); let textDecoder = new TextDecoder();
console.log("Received message:", messageType, messageData);
switch (messageType) {
case MessageType.TEXT:
messages.set([...get(messages), {
initiator: false,
type: messageType,
data: textDecoder.decode(messageData.buffer),
}]);
break;
case MessageType.FILE_OFFER:
let fileSize = messageData.readBigInt64LE();
let fileNameSize = messageData.readInt16LE();
let fileName = messageData.readString(fileNameSize);
let id = messageData.readBigInt64LE();
get(receivedOffers).set(id, { name: fileName, size: fileSize });
messages.set([...get(messages), {
initiator: false,
type: messageType,
data: {
fileSize,
fileNameSize,
fileName,
id,
text: messageData.peek() ? messageData.readString() : null,
}
}]);
break;
case MessageType.FILE_REQUEST:
// the id that coresponds to our file offer
let offerId = messageData.readBigInt64LE();
if (!get(advertisedOffers).has(offerId)) {
console.error("Unknown file offer id:", offerId);
return;
}
let targetFile = get(advertisedOffers).get(offerId)!;
let fileStream = targetFile.stream();
let fileReader = fileStream.getReader();
let idleTimeout = setTimeout(() => {
console.error("Timed out waiting for file ack");
fileReader.cancel();
}, 30000);
// the id we send the file data with
let fileRequestId = messageData.readBigInt64LE();
let fileChunk = await fileReader.read();
// reactive variable to track if the peer received the chunk
fileAck.set(fileRequestId, writable(false));
function sendChunk() {
if (!fileChunk.value) {
clearTimeout(idleTimeout);
fileReader.cancel();
console.error("Chunk not set");
return;
}
// header + id + data
let fileBuf = new WebBuffer(new Uint8Array(1 + 8 + fileChunk.value.byteLength).buffer);
fileBuf.writeInt8(MessageType.FILE);
fileBuf.writeBigInt64LE(fileRequestId);
fileBuf.write(fileChunk.value);
webRtcPeer.send(fileBuf.buffer, WebRTCPacketType.MESSAGE);
}
sendChunk();
let unsubscribe = fileAck.get(fileRequestId)!.subscribe(async (value) => {
if (!value) {
return;
}
fileChunk = await fileReader.read();
if (fileChunk.done) {
// send the done message
let fileDoneBuf = new WebBuffer(new ArrayBuffer(1 + 8));
fileDoneBuf.writeInt8(MessageType.FILE_DONE);
fileDoneBuf.writeBigInt64LE(fileRequestId);
webRtcPeer.send(fileDoneBuf.buffer, WebRTCPacketType.MESSAGE);
// cleanup
fileReader.cancel();
fileAck.delete(fileRequestId);
clearTimeout(idleTimeout);
unsubscribe();
return;
}
sendChunk();
fileAck.get(fileRequestId)!.set(false);
clearTimeout(idleTimeout);
idleTimeout = setTimeout(() => {
console.error("Timed out waiting for file ack");
fileReader.cancel();
}, 30000);
});
console.log("Received file request");
break;
case MessageType.FILE:
let requestId = messageData.readBigInt64LE();
let receivedOffserId = get(fileRequestIds).get(requestId);
if (!receivedOffserId) {
console.error("Received file message for unknown file id:", requestId);
return;
}
let file = get(receivedOffers).get(receivedOffserId);
if (!file) {
console.error("Unknown file id:", requestId);
return;
}
if (downloadStream === undefined) {
window.addEventListener("pagehide", onPageHide);
window.addEventListener("beforeunload", beforeUnload);
downloadStream = window.streamSaver.createWriteStream(file.name, { size: Number(file.size) });
downloadWriter = downloadStream.getWriter();
}
await downloadWriter!.write(new Uint8Array(messageData.read()));
let fileAckBuf = new WebBuffer(new ArrayBuffer(1 + 8));
fileAckBuf.writeInt8(MessageType.FILE_ACK);
fileAckBuf.writeBigInt64LE(requestId);
webRtcPeer.send(fileAckBuf.buffer, WebRTCPacketType.MESSAGE);
break;
case MessageType.FILE_DONE:
console.log("Received file done");
let fileDoneId = messageData.readBigInt64LE();
if (!get(fileRequestIds).has(fileDoneId)) {
console.error("Unknown file done id:", fileDoneId);
return;
}
window.removeEventListener("pagehide", onPageHide);
window.removeEventListener("beforeunload", beforeUnload);
if (downloadWriter) {
downloadWriter.close();
downloadWriter = undefined;
downloadStream = undefined;
}
break;
case MessageType.FILE_ACK:
console.log("Received file ack");
let fileAckId = messageData.readBigInt64LE();
if (!fileAck.has(fileAckId)) {
console.error("Unknown file ack id:", fileAckId);
return;
}
fileAck.get(fileAckId)!.set(true);
break;
default:
console.warn("Unhandled message type:", messageType);
break;
} }
}, },
onDataChannelOpen: () => { onDataChannelStateChange: (state: boolean) => {
console.log("Data channel open"); console.log(`Data channel ${state ? "open" : "closed"}`);
dataChannelReady.set(true); dataChannelReady.set(state);
}, },
onKeyExchangeDone: async () => { onKeyExchangeDone: async () => {
console.log("Key exchange done"); console.log("Key exchange done");

View File

@@ -6,7 +6,7 @@ import { webSocketServer } from './src/websocket.ts';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit(), webSocketServer], plugins: [tailwindcss(), sveltekit(), webSocketServer],
server: { server: {
allowedHosts: ['.trycloudflare.com'], allowedHosts: true,
}, },
ssr: { ssr: {
// ts-mls is problematic, make vite bundle it // ts-mls is problematic, make vite bundle it