change mailbox behaviour to use reception confirmations

This commit is contained in:
marlon-erler
2026-03-31 22:02:13 +00:00
parent 959dc4d288
commit ec020e6358
21 changed files with 2772 additions and 2476 deletions
+38 -38
View File
@@ -5,22 +5,22 @@ The UDN project aims to provide decentralized and localhost-able infrastructure
# Contents # Contents
- [How it works](#how-it-works) - [How it works](#how-it-works)
- [Channels](#channels) - [Channels](#channels)
- [Scalability](#scalability) - [Scalability](#scalability)
- [Example](#example) - [Example](#example)
- [User's guide](#users-guide) - [User's guide](#users-guide)
- [Getting Connected](#getting-connected) - [Getting Connected](#getting-connected)
- [Using Apps](#using-apps) - [Using Apps](#using-apps)
- [Getting Apps](#getting-apps) - [Getting Apps](#getting-apps)
- [Administrator's Guide](#administrators-guide) - [Administrator's Guide](#administrators-guide)
- [Getting Started](#getting-started) - [Getting Started](#getting-started)
- [Configuration](#configuration) - [Configuration](#configuration)
- [TLS](#tls) - [TLS](#tls)
- [Connecting Multiple Servers](#connecting-multiple-servers) - [Connecting Multiple Servers](#connecting-multiple-servers)
- [App Developer's Guide](#app-developers-guide) - [App Developer's Guide](#app-developers-guide)
- [Guidelines](#guidelines) - [Guidelines](#guidelines)
- [The Recommended Way](#the-recommended-way) - [The Recommended Way](#the-recommended-way)
- [The Manual Way](#the-manual-way) - [The Manual Way](#the-manual-way)
# How it works # How it works
@@ -40,14 +40,14 @@ Subscriptions and messages are handled by a server:
```md ```md
**Josh** --(subscribe to "my-channel")-> **Server** **Josh** --(subscribe to "my-channel")-> **Server**
**Josh** <-(subscription confirmed )-- **Server** **Josh** <-(subscription confirmed )-- **Server**
**Bob** --(subscribe to "my-channel")-> **Server** **Bob** --(subscribe to "my-channel")-> **Server**
**Bob** <-(subscription confirmed )-- **Server** **Bob** <-(subscription confirmed )-- **Server**
**Josh** --("hello" on "my-channel" )-> **Server** **Josh** --("hello" on "my-channel" )-> **Server**
**Josh** <-("hello" on "my-channel" )-- **Server** **Josh** <-("hello" on "my-channel" )-- **Server**
**Bob** <-("hello" on "my-channel" )-- **Server** **Bob** <-("hello" on "my-channel" )-- **Server**
``` ```
## Scalability ## Scalability
@@ -79,36 +79,36 @@ Then, John will send his messages on both "s1-and-s2" and "car-warranty". As alw
# John and Bob subscribe: # John and Bob subscribe:
**John** --(subscribe to "car-warranty")-> **S1** **John** --(subscribe to "car-warranty")-> **S1**
**John** <-(subscription confirmed )-- **S1** **John** <-(subscription confirmed )-- **S1**
**Bob** --(subscribe to "car-warranty")-> **S2** **Bob** --(subscribe to "car-warranty")-> **S2**
**Bob** <-(subscription confirmed )-- **S2** **Bob** <-(subscription confirmed )-- **S2**
--- ---
# The servers subscribe: # The servers subscribe:
**S1** --(subscribe to "s1-and-s2" )-> **S2** **S1** --(subscribe to "s1-and-s2" )-> **S2**
**S2** --(subscribe to "s1-and-s2" )-> **S1** **S2** --(subscribe to "s1-and-s2" )-> **S1**
--- ---
# Sending the Message: # Sending the Message:
John sends the message to his server John sends the message to his server
**John** --("hello" on: )-> **S1** **John** --("hello" on: )-> **S1**
( - "s1-and-s2" ) ( - "s1-and-s2" )
( - "car-warranty" ) ( - "car-warranty" )
S2 subscribed to "s1-and-s2", so S1 sends this message to S2 S2 subscribed to "s1-and-s2", so S1 sends this message to S2
**S1** --("hello" on: )-> **S2** **S1** --("hello" on: )-> **S2**
( - "s1-and-s2" ) ( - "s1-and-s2" )
( - "car-warranty" ) ( - "car-warranty" )
Bob subscribed to "car-warranty", so S2 sends this message to Bob Bob subscribed to "car-warranty", so S2 sends this message to Bob
**S2** --("hello" on: )-> **Bob** **S2** --("hello" on: )-> **Bob**
( - "s1-and-s2" ) ( - "s1-and-s2" )
( - "car-warranty" ) ( - "car-warranty" )
``` ```
This requires John to know of "s1-and-s2" and, obviously, Bob to subscribe to "car-warranty". Jane and Alice could agree on their own primary channel and use "s1-and-s2" just like John did. This requires John to know of "s1-and-s2" and, obviously, Bob to subscribe to "car-warranty". Jane and Alice could agree on their own primary channel and use "s1-and-s2" just like John did.
@@ -139,16 +139,16 @@ As you can see, there are many ways to connect multiple servers and communicate
- `http://` or `https://` (the secure variant) are protocols to request data and receive a single response - `http://` or `https://` (the secure variant) are protocols to request data and receive a single response
- To send messages, apps use so-called WebSockets instead. Their protocol is `ws://` or `wss://` (the secure variant) - To send messages, apps use so-called WebSockets instead. Their protocol is `ws://` or `wss://` (the secure variant)
- You will need to **replace `http` with `ws`**: - You will need to **replace `http` with `ws`**:
- `http://192.168.0.100:3000` -> `ws://192.168.0.100:3000` - `http://192.168.0.100:3000` -> `ws://192.168.0.100:3000`
- `https://192.168.0.200:3000` -> `wss://192.168.0.200:3000` - `https://192.168.0.200:3000` -> `wss://192.168.0.200:3000`
## Getting Apps ## Getting Apps
Anyone can develop apps for the UDN project. Examples of such apps are listed below. If you have developed an app for the UDN project and you want your app to be listed here, create an issue. Anyone can develop apps for the UDN project. Examples of such apps are listed below. If you have developed an app for the UDN project and you want your app to be listed here, create an issue.
| App Name | Description | Links | | App Name | Description | Links |
| -- | -- | -- | | -------- | ----------------- | ---------------------------------------------------------------------------------------------------- |
| Comms | Messenger for UDN | [more info](https://github.com/marlon-erler/udn-comms/) - [install](https://udn-comms.onrender.com/) | Comms | Messenger for UDN | [more info](https://github.com/marlon-erler/udn-comms/) - [install](https://udn-comms.onrender.com/) |
--- ---
+17 -17
View File
@@ -2,30 +2,30 @@
const configPath = "config.json"; const configPath = "config.json";
export const defaultConfig = { export const defaultConfig = {
port: 3000, port: 3000,
connectedServers: [], connectedServers: [],
subscribedChannels: [], subscribedChannels: [],
}; };
// METHODS // METHODS
export async function createConfig() { export async function createConfig() {
const configString = JSON.stringify(defaultConfig, null, 4); const configString = JSON.stringify(defaultConfig, null, 4);
await Bun.write(configPath, configString); await Bun.write(configPath, configString);
} }
export async function getConfig(): Promise<typeof defaultConfig> { export async function getConfig(): Promise<typeof defaultConfig> {
try { try {
const configString = await Bun.file(configPath).text(); const configString = await Bun.file(configPath).text();
const configObject = JSON.parse(configString); const configObject = JSON.parse(configString);
// validate config file // validate config file
Object.keys(defaultConfig).forEach((key) => { Object.keys(defaultConfig).forEach((key) => {
if (!configObject[key]) throw null; if (!configObject[key]) throw null;
}); });
return configObject; return configObject;
} catch { } catch {
await createConfig(); await createConfig();
return defaultConfig; return defaultConfig;
} }
} }
+117 -116
View File
@@ -1,21 +1,21 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { import {
connectServers, connectServers,
forgetConnection, forgetConnection,
getWebSocketStats, getWebSocketStats,
processMessage, processMessage,
reconnectServers, reconnectServers,
removeExpiredMailboxes, removeExpiredMailboxes,
trackConnection, trackConnection,
} from "./websocket-handler"; } from "./websocket-handler";
import { import {
createFileResponse, createFileResponse,
getVersionNumber, getVersionNumber,
writeError, writeError,
writeStatNumber, writeStatNumber,
writeStatString, writeStatString,
writeSuccess, writeSuccess,
} from "./utility"; } from "./utility";
import Colors from "colors"; import Colors from "colors";
@@ -35,120 +35,121 @@ export type WS = ServerWebSocket<unknown> | WebSocket;
// MAIN // MAIN
async function main() { async function main() {
// CONFIG // CONFIG
const config = await getConfig(); const config = await getConfig();
const versionNumber = await getVersionNumber(); const versionNumber = await getVersionNumber();
// SERVER // SERVER
const server = Bun.serve({ const server = Bun.serve({
port: config.port, port: config.port,
fetch(request, server) { fetch(request, server) {
// allow websockets // allow websockets
server.upgrade(request); server.upgrade(request);
// respond // respond
const requestPath = new URL(request.url).pathname; const requestPath = new URL(request.url).pathname;
switch (requestPath) { switch (requestPath) {
case "/": case "/":
return createFileResponse(DEFAULT_PAGE); return createFileResponse(DEFAULT_PAGE);
case "/stats": case "/stats":
return new Response(JSON.stringify(getAllStats())); return new Response(JSON.stringify(getAllStats()));
default: default:
return createFileResponse(requestPath); return createFileResponse(requestPath);
} }
}, },
websocket: { websocket: {
open(ws) { open(ws) {
try { try {
trackConnection(ws); trackConnection(ws);
} catch (error) { } catch (error) {
writeError(error); writeError(error);
} }
}, },
message(ws, message) { message(ws, message) {
try { try {
processMessage(ws, message.toString(), config); processMessage(ws, message.toString(), config);
} catch (error) { } catch (error) {
writeError(error); writeError(error);
} }
}, },
close(ws) { close(ws) {
try { try {
forgetConnection(ws); forgetConnection(ws);
} catch (error) { } catch (error) {
writeError(error); writeError(error);
} }
}, },
}, },
tls: await (async () => { tls: await (async () => {
const keyFile = Bun.file("./key.pem"); const keyFile = Bun.file("./key.pem");
const certFile = Bun.file("./cert.pem"); const certFile = Bun.file("./cert.pem");
if ( if (
(await keyFile.exists()) == false || (await keyFile.exists()) == false ||
(await certFile.exists()) == false (await certFile.exists()) == false
) )
return undefined; return undefined;
return { return {
key: keyFile, key: keyFile,
cert: certFile, cert: certFile,
passphrase: process.env.TLS_PASSPHRASE, passphrase: process.env.TLS_PASSPHRASE,
}; };
})(), })(),
}); });
// OTHER SERVERS // OTHER SERVERS
connectServers(config); connectServers(config);
// AUDIT // AUDIT
function getServerStats(): [string, string][] { function getServerStats(): [string, string][] {
return [ return [
["server url", server.url.toString()], ["server url", server.url.toString()],
["server ip address", IP.address()], ["server ip address", IP.address()],
["server version", versionNumber], ["server version", versionNumber],
]; ];
}
function getAllStats(): [string, string | number][] {
return [...getServerStats(), ...getWebSocketStats()];
}
// CLI
function updateCLI() {
console.clear();
console.log(Colors.bold.bgWhite("UNIVERSAL DECENTRALIZED NETWORK"));
console.log(
Colors.yellow("relevant output is available in the 'logs' file")
);
console.log();
console.log(new Date().toLocaleString());
console.log();
getServerStats().forEach((entry) => writeStatString(...entry));
console.log();
getWebSocketStats().forEach((entry) => writeStatNumber(...entry));
console.log();
}
// LOOP
setInterval(() => {
if (process.env.DEV != "1") {
updateCLI();
reconnectServers(config);
} }
removeExpiredMailboxes();
}, 2000);
// FINISH function getAllStats(): [string, string | number][] {
writeSuccess(`###\nstarted up ${new Date().toLocaleString()}`); return [...getServerStats(), ...getWebSocketStats()];
}
// CLI
function updateCLI() {
return;
console.clear();
console.log(Colors.bold.bgWhite("UNIVERSAL DECENTRALIZED NETWORK"));
console.log(
Colors.yellow("relevant output is available in the 'logs' file"),
);
console.log();
console.log(new Date().toLocaleString());
console.log();
getServerStats().forEach((entry) => writeStatString(...entry));
console.log();
getWebSocketStats().forEach((entry) => writeStatNumber(...entry));
console.log();
}
// LOOP
setInterval(() => {
if (process.env.DEV != "1") {
updateCLI();
reconnectServers(config);
}
removeExpiredMailboxes();
}, 2000);
// FINISH
writeSuccess(`###\nstarted up ${new Date().toLocaleString()}`);
} }
main(); main();
+748 -748
View File
File diff suppressed because it is too large Load Diff
+23 -23
View File
@@ -1,25 +1,25 @@
{ {
"name": "udn", "name": "udn",
"version": "1.0.0-beta14", "version": "1.0.0-beta15",
"description": "A websocket-based infrastructure for decentralized networks ", "description": "A websocket-based infrastructure for decentralized networks ",
"bin": { "bin": {
"start": "index.ts" "start": "index.ts"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/marlon-erler/universal-decentralized-network.git" "url": "git+https://github.com/marlon-erler/universal-decentralized-network.git"
}, },
"author": "marlon-erler", "author": "marlon-erler",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"bugs": { "bugs": {
"url": "https://github.com/marlon-erler/universal-decentralized-network/issues" "url": "https://github.com/marlon-erler/universal-decentralized-network/issues"
}, },
"homepage": "https://github.com/marlon-erler/universal-decentralized-network#readme", "homepage": "https://github.com/marlon-erler/universal-decentralized-network#readme",
"dependencies": { "dependencies": {
"@types/bun": "^1.1.6", "@types/bun": "^1.1.6",
"@types/ip": "^1.1.3", "@types/ip": "^1.1.3",
"colors": "^1.4.0", "colors": "^1.4.0",
"express": "^4.19.2", "express": "^4.19.2",
"ip": "^2.0.1" "ip": "^2.0.1"
} }
} }
+27 -27
View File
@@ -1,34 +1,34 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>UDN Server</title> <title>UDN Server</title>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no"
/> />
<meta <meta
name="theme-color" name="theme-color"
content="hsla(0, 0%, 100%, 100%)" content="hsla(0, 0%, 100%, 100%)"
media="(prefers-color-scheme: light)" media="(prefers-color-scheme: light)"
/> />
<meta <meta
name="theme-color" name="theme-color"
content="black" content="black"
media="(prefers-color-scheme: dark)" media="(prefers-color-scheme: dark)"
/> />
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
<link rel="stylesheet" href="/index.css" /> <link rel="stylesheet" href="/index.css" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/icon.png" /> <link rel="icon" href="/icon.png" />
<script src="/support.js" defer></script> <script src="/support.js" defer></script>
<script src="/index.js" defer></script> <script src="/index.js" defer></script>
</head> </head>
<body> <body>
<main></main> <main></main>
</body> </body>
</html> </html>
+797 -547
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -1,10 +1,10 @@
{ {
"name": "UDN Server", "name": "UDN Server",
"display": "standalone", "display": "standalone",
"icons": [ "icons": [
{ {
"src": "icon.png", "src": "icon.png",
"sizes": "any" "sizes": "any"
} }
] ]
} }
+401 -396
View File
File diff suppressed because it is too large Load Diff
+22 -19
View File
@@ -1,31 +1,34 @@
// TABS // TABS
document.querySelector("main").addEventListener("scroll", () => { document.querySelector("main").addEventListener("scroll", () => {
const links = document.querySelectorAll(".tab-link"); const links = document.querySelectorAll(".tab-link");
links.forEach((element) => element.removeAttribute("active")); links.forEach((element) => element.removeAttribute("active"));
const pageWidth = document.querySelector("main > article").offsetWidth; const pageWidth = document.querySelector("main > article").offsetWidth;
const scroll = document.querySelector("main").scrollLeft; const scroll = document.querySelector("main").scrollLeft;
const currentPage = Math.round(scroll / pageWidth); const currentPage = Math.round(scroll / pageWidth);
if (links[currentPage]) links[currentPage].setAttribute("active", ""); if (links[currentPage]) links[currentPage].setAttribute("active", "");
}); });
// SERVICE WORKER // SERVICE WORKER
async function registerServiceWorker() { async function registerServiceWorker() {
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
try { try {
const registration = await navigator.serviceWorker.register("/sw.js", { const registration = await navigator.serviceWorker.register(
scope: "/", "/sw.js",
}); {
if (registration.installing) { scope: "/",
setTimeout(() => window.location.reload(), 500); },
} else if (registration.active) { );
console.log("Service worker active"); if (registration.installing) {
} setTimeout(() => window.location.reload(), 500);
} catch (error) { } else if (registration.active) {
console.error(`Could not install service worker: ${error}`); console.log("Service worker active");
}
} catch (error) {
console.error(`Could not install service worker: ${error}`);
}
} }
}
} }
registerServiceWorker(); registerServiceWorker();
+18 -18
View File
@@ -1,38 +1,38 @@
const CACHE_VERSION = "v1"; const CACHE_VERSION = "v1";
async function handleRequest(event) { async function handleRequest(event) {
const responsePromise = getResponse(event); const responsePromise = getResponse(event);
event.respondWith(responsePromise); event.respondWith(responsePromise);
} }
async function getResponse(event) { async function getResponse(event) {
try { try {
return await fetchAndCache(event); return await fetchAndCache(event);
} catch { } catch {
return await getFromCache(event); return await getFromCache(event);
} }
} }
async function fetchAndCache(event) { async function fetchAndCache(event) {
const request = event.request; const request = event.request;
const response = await fetch(request); const response = await fetch(request);
const cache = await caches.open(CACHE_VERSION); const cache = await caches.open(CACHE_VERSION);
await cache.put(request, response.clone()); await cache.put(request, response.clone());
return response; return response;
} }
async function getFromCache(event) { async function getFromCache(event) {
const request = event.request; const request = event.request;
const cache = await caches.open(CACHE_VERSION); const cache = await caches.open(CACHE_VERSION);
const response = await cache.match(request); const response = await cache.match(request);
return response; return response;
} }
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
handleRequest(event); handleRequest(event);
}); });
+8 -8
View File
@@ -1,10 +1,10 @@
{ {
"dependencies": { "dependencies": {
"bloatless-react": "^1.2.6", "bloatless-react": "^1.2.6",
"esbuild": "latest", "esbuild": "latest",
"udn-frontend": "^1.0.6" "udn-frontend": "^1.0.6"
}, },
"scripts": { "scripts": {
"build": "esbuild src/index.tsx --bundle --outdir=dist" "build": "esbuild src/index.tsx --bundle --outdir=dist"
} }
} }
+31 -31
View File
@@ -7,41 +7,41 @@ import { isDisconnected } from "./model";
import { translations } from "./translations"; import { translations } from "./translations";
document.body.prepend( document.body.prepend(
<menu> <menu>
<a class="tab-link" href="#info-screen" active> <a class="tab-link" href="#info-screen" active>
<span class="icon">info</span> <span class="icon">info</span>
{translations.info} {translations.info}
</a> </a>
<a class="tab-link" href="#main-screen"> <a class="tab-link" href="#main-screen">
<span class="icon">settings</span> <span class="icon">settings</span>
{translations.settings} {translations.settings}
</a> </a>
<a class="tab-link" href="#message-screen"> <a class="tab-link" href="#message-screen">
<span class="icon">chat</span> <span class="icon">chat</span>
{translations.messages} {translations.messages}
</a> </a>
</menu> </menu>,
); );
document document
.querySelector("main")! .querySelector("main")!
.append(InfoScreen(), MainScreen(), MessageScreen()); .append(InfoScreen(), MainScreen(), MessageScreen());
document.body.append( document.body.append(
<div class="modal" toggle:open={isDisconnected}> <div class="modal" toggle:open={isDisconnected}>
<div> <div>
<main> <main>
<div <div
class="flex-column align-center justify-center width-100 height-100" class="flex-column align-center justify-center width-100 height-100"
style="gap: 1rem" style="gap: 1rem"
> >
<span class="icon error" style="font-size: 3rem"> <span class="icon error" style="font-size: 3rem">
signal_disconnected signal_disconnected
</span> </span>
<h1 class="error">{translations.disconnected}</h1> <h1 class="error">{translations.disconnected}</h1>
<p class="secondary">{translations.reconnecting}</p> <p class="secondary">{translations.reconnecting}</p>
</div>
</main>
</div> </div>
</main> </div>,
</div>
</div>
); );
+18 -15
View File
@@ -5,19 +5,22 @@ import { statHTMLString, updateStats } from "./model";
import { translations } from "./translations"; import { translations } from "./translations";
export function InfoScreen() { export function InfoScreen() {
return ( return (
<article id="info-screen"> <article id="info-screen">
<header> <header>
{translations.serverInfo} {translations.serverInfo}
<span> <span>
<button on:click={updateStats}> <button on:click={updateStats}>
<span class="icon">refresh</span> <span class="icon">refresh</span>
</button> </button>
</span> </span>
</header> </header>
<div> <div>
<div class="flex-column gap" subscribe:innerHTML={statHTMLString}></div> <div
</div> class="flex-column gap"
</article> subscribe:innerHTML={statHTMLString}
); ></div>
</div>
</article>
);
} }
+109 -103
View File
@@ -1,122 +1,128 @@
import * as React from "bloatless-react"; import * as React from "bloatless-react";
import { import {
deleteMailbox, deleteMailbox,
isDisconnected, isDisconnected,
isMailboxDisonnected, isMailboxDisonnected,
isMessageEmpty, isMessageEmpty,
lastReceivedMessage, lastReceivedMessage,
newMessageBody, newMessageBody,
newMessageChannel, newMessageChannel,
requestMailbox, requestMailbox,
sendMessage, sendMessage,
subscribe, subscribe,
subscriptionChannel, subscriptionChannel,
unsubscribe, unsubscribe,
} from "./model"; } from "./model";
import { translations } from "./translations"; import { translations } from "./translations";
export function MainScreen() { export function MainScreen() {
// UI // UI
return ( return (
<article id="main-screen"> <article id="main-screen">
<header>{translations.settings}</header> <header>{translations.settings}</header>
<div> <div>
<div class="tile width-input"> <div class="tile width-input">
<div> <div>
<b>{translations.messageLastReceived}</b> <b>{translations.messageLastReceived}</b>
<p class="secondary" subscribe:innerText={lastReceivedMessage}></p> <p
</div> class="secondary"
</div> subscribe:innerText={lastReceivedMessage}
></p>
</div>
</div>
<hr></hr> <hr></hr>
<div class="tile width-input"> <div class="tile width-input">
<div> <div>
<b>{translations.mailbox}</b> <b>{translations.mailbox}</b>
<p class="secondary" toggle:hidden={isMailboxDisonnected}> <p
{translations.mailboxConnected} class="secondary"
</p> toggle:hidden={isMailboxDisonnected}
</div> >
</div> {translations.mailboxConnected}
</p>
</div>
</div>
<div class="flex-row width-input justify-end"> <div class="flex-row width-input justify-end">
<button <button
class="danger width-50" class="danger width-50"
on:click={deleteMailbox} on:click={deleteMailbox}
toggle:disabled={isMailboxDisonnected} toggle:disabled={isMailboxDisonnected}
> >
{translations.deleteMailbox} {translations.deleteMailbox}
</button> </button>
<button <button
class="primary width-50" class="primary width-50"
on:click={requestMailbox} on:click={requestMailbox}
toggle:disabled={isDisconnected} toggle:disabled={isDisconnected}
> >
{translations.requestMailbox} {translations.requestMailbox}
<span class="icon">inbox</span> <span class="icon">inbox</span>
</button> </button>
</div> </div>
<hr></hr> <hr></hr>
<label class="tile"> <label class="tile">
<div> <div>
<span>{translations.subscribeToChannel}</span> <span>{translations.subscribeToChannel}</span>
<input <input
placeholder={translations.channel_placeholder} placeholder={translations.channel_placeholder}
bind:value={subscriptionChannel} bind:value={subscriptionChannel}
on:enter={subscribe} on:enter={subscribe}
></input> ></input>
</div> </div>
</label> </label>
<div class="flex-row width-input justify-end"> <div class="flex-row width-input justify-end">
<button class="danger width-50" on:click={unsubscribe}> <button class="danger width-50" on:click={unsubscribe}>
{translations.unsubscribe} {translations.unsubscribe}
</button> </button>
<button class="primary width-50" on:click={subscribe}> <button class="primary width-50" on:click={subscribe}>
{translations.subscribe} {translations.subscribe}
<span class="icon">arrow_forward</span> <span class="icon">arrow_forward</span>
</button> </button>
</div> </div>
<hr></hr> <hr></hr>
<label class="tile"> <label class="tile">
<div> <div>
<span>{translations.channel}</span> <span>{translations.channel}</span>
<input <input
placeholder={translations.channel_placeholder} placeholder={translations.channel_placeholder}
bind:value={newMessageChannel} bind:value={newMessageChannel}
on:enter={sendMessage} on:enter={sendMessage}
></input> ></input>
</div> </div>
</label> </label>
<label class="tile"> <label class="tile">
<div> <div>
<span>{translations.message}</span> <span>{translations.message}</span>
<input <input
placeholder={translations.message_placeholder} placeholder={translations.message_placeholder}
bind:value={newMessageBody} bind:value={newMessageBody}
on:enter={sendMessage} on:enter={sendMessage}
></input> ></input>
</div> </div>
</label> </label>
<div class="flex-row width-input justify-end"> <div class="flex-row width-input justify-end">
<button <button
class="primary width-50" class="primary width-50"
on:click={sendMessage} on:click={sendMessage}
toggle:disabled={isMessageEmpty} toggle:disabled={isMessageEmpty}
> >
{translations.send} {translations.send}
<span class="icon">send</span> <span class="icon">send</span>
</button> </button>
</div> </div>
</div> </div>
</article> </article>
); );
} }
+39 -39
View File
@@ -1,51 +1,51 @@
import * as React from "bloatless-react"; import * as React from "bloatless-react";
import { import {
Message, Message,
isMessageEmpty, isMessageEmpty,
messages, messages,
newMessageBody, newMessageBody,
sendMessage, sendMessage,
} from "./model"; } from "./model";
import { translations } from "./translations"; import { translations } from "./translations";
const convertMessageToElement: React.ListItemConverter<Message> = (message) => { const convertMessageToElement: React.ListItemConverter<Message> = (message) => {
return ( return (
<div class="tile" style="flex: 0"> <div class="tile" style="flex: 0">
<div> <div>
<span class="secondary">{message.channel}</span> <span class="secondary">{message.channel}</span>
<b>{message.body}</b> <b>{message.body}</b>
</div> </div>
</div> </div>
); );
}; };
export function MessageScreen() { export function MessageScreen() {
return ( return (
<article id="message-screen"> <article id="message-screen">
<header>{translations.messagesReceived}</header> <header>{translations.messagesReceived}</header>
<div> <div>
<div <div
class="flex-column gap" class="flex-column gap"
subscribe:children={[messages, convertMessageToElement]} subscribe:children={[messages, convertMessageToElement]}
></div> ></div>
</div> </div>
<footer> <footer>
<input <input
style="max-width: unset" style="max-width: unset"
placeholder={translations.message_placeholder_generic} placeholder={translations.message_placeholder_generic}
bind:value={newMessageBody} bind:value={newMessageBody}
on:enter={sendMessage} on:enter={sendMessage}
></input> ></input>
<button <button
class="primary" class="primary"
on:click={sendMessage} on:click={sendMessage}
toggle:disabled={isMessageEmpty} toggle:disabled={isMessageEmpty}
> >
<span class="icon">send</span> <span class="icon">send</span>
</button> </button>
</footer> </footer>
</article> </article>
); );
} }
+55 -50
View File
@@ -4,7 +4,10 @@ import UDNFrontend from "udn-frontend";
import { translations } from "./translations"; import { translations } from "./translations";
export class Message { export class Message {
constructor(public channel: string, public body: string) {} constructor(
public channel: string,
public body: string,
) {}
} }
// STATE // STATE
@@ -18,28 +21,30 @@ export const isMailboxDisonnected = new React.State(true);
// messages // messages
export const messages = new React.ListState<Message>(); export const messages = new React.ListState<Message>();
export const lastReceivedMessage = new React.State( export const lastReceivedMessage = new React.State(
translations.noMessagesReceived translations.noMessagesReceived,
); );
// stats // stats
export const statHTMLString = new React.State<string>(""); export const statHTMLString = new React.State<string>("");
export async function updateStats() { export async function updateStats() {
const response = await fetch( const response = await fetch(
`${window.location.protocol}//${window.location.host}/stats` `${window.location.protocol}//${window.location.host}/stats`,
);
const data: [string, string][] = await response.json();
let html = "";
data.forEach((item) => {
const tile = (
<div class="tile">
<span class="secondary flex width-100">{item[0].toString()}</span>
<b>{item[1].toString()}</b>
</div>
); );
html += tile.outerHTML; const data: [string, string][] = await response.json();
});
statHTMLString.value = html; let html = "";
data.forEach((item) => {
const tile = (
<div class="tile">
<span class="secondary flex width-100">
{item[0].toString()}
</span>
<b>{item[1].toString()}</b>
</div>
);
html += tile.outerHTML;
});
statHTMLString.value = html;
} }
// channels // channels
@@ -49,79 +54,79 @@ export const newMessageChannel = new React.State("");
export const newMessageBody = new React.State(""); export const newMessageBody = new React.State("");
export const isMessageEmpty = React.createProxyState( export const isMessageEmpty = React.createProxyState(
[newMessageChannel, newMessageBody], [newMessageChannel, newMessageBody],
() => newMessageChannel.value == "" || newMessageBody.value == "" () => newMessageChannel.value == "" || newMessageBody.value == "",
); );
// UDN Frontend // UDN Frontend
const UDN = new UDNFrontend(); const UDN = new UDNFrontend();
UDN.onconnect = () => { UDN.onconnect = () => {
isDisconnected.value = false; isDisconnected.value = false;
updateStats(); updateStats();
if (mailboxId.value != "") { if (mailboxId.value != "") {
UDN.connectMailbox(mailboxId.value); UDN.connectMailbox(mailboxId.value);
} }
}; };
UDN.onmessage = (data) => { UDN.onmessage = (data) => {
lastReceivedMessage.value = JSON.stringify(data, null, 4); lastReceivedMessage.value = JSON.stringify(data, null, 4);
const { messageChannel, messageBody } = data; const { messageChannel, messageBody } = data;
if (messageChannel && messageBody) { if (messageChannel && messageBody) {
const messageObject = new Message(messageChannel, messageBody); const messageObject = new Message(messageChannel, messageBody);
if (messageBody) messages.add(messageObject); if (messageBody) messages.add(messageObject);
} }
}; };
UDN.onmailboxcreate = (id) => { UDN.onmailboxcreate = (id) => {
console.log(id); console.log(id);
mailboxId.value = id; mailboxId.value = id;
UDN.connectMailbox(id); UDN.connectMailbox(id);
}; };
UDN.onmailboxconnect = () => { UDN.onmailboxconnect = () => {
isMailboxDisonnected.value = false; isMailboxDisonnected.value = false;
}; };
UDN.onmailboxdelete = () => { UDN.onmailboxdelete = () => {
isMailboxDisonnected.value = true; isMailboxDisonnected.value = true;
} };
UDN.ondisconnect = () => { UDN.ondisconnect = () => {
isDisconnected.value = true; isDisconnected.value = true;
isMailboxDisonnected.value = true; isMailboxDisonnected.value = true;
setTimeout(connect, 1000); setTimeout(connect, 1000);
}; };
function connect() { function connect() {
UDN.connect( UDN.connect(
`${window.location.protocol.replace("http", "ws")}//${window.location.host}` `${window.location.protocol.replace("http", "ws")}//${window.location.host}`,
); );
} }
connect(); connect();
// METHODS // METHODS
export function subscribe() { export function subscribe() {
UDN.subscribe(subscriptionChannel.value); UDN.subscribe(subscriptionChannel.value);
} }
export function unsubscribe() { export function unsubscribe() {
UDN.unsubscribe(subscriptionChannel.value); UDN.unsubscribe(subscriptionChannel.value);
} }
export function sendMessage() { export function sendMessage() {
if (isMessageEmpty.value == true) return; if (isMessageEmpty.value == true) return;
UDN.sendMessage(newMessageChannel.value, newMessageBody.value); UDN.sendMessage(newMessageChannel.value, newMessageBody.value);
newMessageBody.value = ""; newMessageBody.value = "";
} }
export function requestMailbox() { export function requestMailbox() {
UDN.requestMailbox(); UDN.requestMailbox();
} }
export function deleteMailbox() { export function deleteMailbox() {
console.log("test"); console.log("test");
UDN.deleteMailbox(mailboxId.value); UDN.deleteMailbox(mailboxId.value);
} }
+71 -71
View File
@@ -1,79 +1,79 @@
// STATIC // STATIC
const staticTextEnglish = { const staticTextEnglish = {
mailbox: "Mailbox", mailbox: "Mailbox",
requestMailbox: "Request", requestMailbox: "Request",
deleteMailbox: "Delete", deleteMailbox: "Delete",
mailboxConnected: "Mailbox active", mailboxConnected: "Mailbox active",
channel: "Channel", channel: "Channel",
channel_placeholder: "my-channel", channel_placeholder: "my-channel",
disconnected: "Server disconnected", disconnected: "Server disconnected",
reconnecting: "Reconnecting...", reconnecting: "Reconnecting...",
info: "Info", info: "Info",
message: "Message", message: "Message",
messages: "Messages", messages: "Messages",
message_placeholder: "Hello, world!", message_placeholder: "Hello, world!",
message_placeholder_generic: "Type a message...", message_placeholder_generic: "Type a message...",
noMessagesReceived: "No messages received", noMessagesReceived: "No messages received",
messageLastReceived: "Last received message", messageLastReceived: "Last received message",
messagesReceived: "Received Messages", messagesReceived: "Received Messages",
send: "Send", send: "Send",
serverInfo: "Server Info", serverInfo: "Server Info",
settings: "Settings", settings: "Settings",
subscribeToChannel: "Subscribe to channel", subscribeToChannel: "Subscribe to channel",
subscribe: "Subscribe", subscribe: "Subscribe",
unsubscribe: "Unubscribe", unsubscribe: "Unubscribe",
}; };
const allTranslations: { [key: string]: typeof staticTextEnglish } = { const allTranslations: { [key: string]: typeof staticTextEnglish } = {
en: staticTextEnglish, en: staticTextEnglish,
es: { es: {
mailbox: "Buzón", mailbox: "Buzón",
requestMailbox: "Solicitar", requestMailbox: "Solicitar",
deleteMailbox: "Eliminar", deleteMailbox: "Eliminar",
mailboxConnected: "Buzón activado", mailboxConnected: "Buzón activado",
channel: "Canal", channel: "Canal",
channel_placeholder: "mi-canal", channel_placeholder: "mi-canal",
disconnected: "Sin coneción", disconnected: "Sin coneción",
reconnecting: "Conectando...", reconnecting: "Conectando...",
info: "Info", info: "Info",
message: "Mensaje", message: "Mensaje",
messages: "Mensajes", messages: "Mensajes",
message_placeholder: "Hola!", message_placeholder: "Hola!",
message_placeholder_generic: "Nuevo mensaje", message_placeholder_generic: "Nuevo mensaje",
noMessagesReceived: "Sin mensajes", noMessagesReceived: "Sin mensajes",
messageLastReceived: "Último mensaje", messageLastReceived: "Último mensaje",
messagesReceived: "Todos los Mensajes", messagesReceived: "Todos los Mensajes",
send: "Enviar", send: "Enviar",
serverInfo: "Información", serverInfo: "Información",
settings: "Configuración", settings: "Configuración",
subscribeToChannel: "Suscribirse a un canal", subscribeToChannel: "Suscribirse a un canal",
subscribe: "Suscribirse", subscribe: "Suscribirse",
unsubscribe: "Cancelar", unsubscribe: "Cancelar",
}, },
de: { de: {
mailbox: "Briefkasten", mailbox: "Briefkasten",
requestMailbox: "Beantragen", requestMailbox: "Beantragen",
deleteMailbox: "Löschen", deleteMailbox: "Löschen",
mailboxConnected: "Briefkasten aktiv", mailboxConnected: "Briefkasten aktiv",
channel: "Kanal", channel: "Kanal",
channel_placeholder: "mein-kanal", channel_placeholder: "mein-kanal",
disconnected: "Verbindung getrennt", disconnected: "Verbindung getrennt",
reconnecting: "Verbindung wird hergestellt...", reconnecting: "Verbindung wird hergestellt...",
info: "Daten", info: "Daten",
message: "Nachricht", message: "Nachricht",
messages: "Nachrichten", messages: "Nachrichten",
message_placeholder: "Hallo!", message_placeholder: "Hallo!",
message_placeholder_generic: "Neue Nachricht", message_placeholder_generic: "Neue Nachricht",
noMessagesReceived: "Keine Nachrichten", noMessagesReceived: "Keine Nachrichten",
messageLastReceived: "Letzte Nachricht", messageLastReceived: "Letzte Nachricht",
messagesReceived: "Empfangene Nachrichten", messagesReceived: "Empfangene Nachrichten",
send: "Senden", send: "Senden",
serverInfo: "Serverdaten", serverInfo: "Serverdaten",
settings: "Einstellungen", settings: "Einstellungen",
subscribeToChannel: "Kanal beitreten", subscribeToChannel: "Kanal beitreten",
subscribe: "Beitreten", subscribe: "Beitreten",
unsubscribe: "Verlassen", unsubscribe: "Verlassen",
}, },
}; };
const language = navigator.language.substring(0, 2); const language = navigator.language.substring(0, 2);
+17 -11
View File
@@ -8,7 +8,7 @@ import Crypto from "crypto";
import { WS } from "./index"; import { WS } from "./index";
export interface Subscriber { export interface Subscriber {
send: (message: string) => void; send: (message: string, compress?: boolean, id?: string) => void;
} }
export class Mailbox implements Subscriber { export class Mailbox implements Subscriber {
@@ -35,7 +35,7 @@ export class Mailbox implements Subscriber {
{ {
assignedMailboxId: this.id, assignedMailboxId: this.id,
}, },
ws ws,
); );
} }
@@ -46,7 +46,7 @@ export class Mailbox implements Subscriber {
{ {
connectedMailboxId: this.id, connectedMailboxId: this.id,
}, },
this.ws this.ws,
); );
} }
@@ -57,7 +57,7 @@ export class Mailbox implements Subscriber {
} }
// storage // storage
messages = new Set<string>(); messages = new Map<string, string>();
// websocket // websocket
private _ws: WS | undefined; private _ws: WS | undefined;
@@ -90,13 +90,13 @@ export class Mailbox implements Subscriber {
} }
// sending // sending
send(message: string): void { send(message: string, _?: boolean, uuid?: string): void {
if (this._ws?.readyState == 1) { this._ws?.send(message);
this._ws.send(message);
this.dateLastUsed = new Date(); console.log(this.id, uuid, message, "sending");
} else {
this.messages.add(message); if (!uuid) return;
} this.messages.set(uuid, message);
} }
sendUnreadMessages(): void { sendUnreadMessages(): void {
@@ -107,6 +107,12 @@ export class Mailbox implements Subscriber {
this.messages.delete(message); this.messages.delete(message);
}); });
} }
checkMessageReceivedConfirmation(uuid: string): void {
if (!this.messages.has(uuid)) return;
this.dateLastUsed = new Date();
this.messages.delete(uuid);
}
} }
export class SubscriptionMap { export class SubscriptionMap {
+26 -26
View File
@@ -3,67 +3,67 @@ import { PACKAGE_FILE, STATIC_DIR, WS } from ".";
import Colors from "colors"; import Colors from "colors";
import Fs from "fs/promises"; import Fs from "fs/promises";
import Path from "path"; import Path from "path";
import { import { WebSocketMessage } from "./websocket-handler";
WebSocketMessage,
} from "./websocket-handler";
// CONFIG // CONFIG
const STAT_INDENT = 25; const STAT_INDENT = 25;
// MISC // MISC
export function doIfIsString(data: any, cb: (data: string) => void): void { export function doIfIsString(data: any, cb: (data: string) => void): void {
if (typeof data != "string" || data == "") return; if (typeof data != "string" || data == "") return;
cb(data); cb(data);
} }
export async function getVersionNumber(): Promise<string> { export async function getVersionNumber(): Promise<string> {
try { try {
const packageString = await Bun.file(PACKAGE_FILE).text(); const packageString = await Bun.file(PACKAGE_FILE).text();
const packageObject = JSON.parse(packageString); const packageObject = JSON.parse(packageString);
return packageObject.version; return packageObject.version;
} catch { } catch {
return "unknown"; return "unknown";
} }
} }
// SERVER // SERVER
export function createFileResponse(requestPath: string): Response { export function createFileResponse(requestPath: string): Response {
const fullPath = Path.join(STATIC_DIR, requestPath); const fullPath = Path.join(STATIC_DIR, requestPath);
return new Response(Bun.file(fullPath)); return new Response(Bun.file(fullPath));
} }
// OUTPUT // OUTPUT
async function writeToLogFile(message: string): Promise<void> { async function writeToLogFile(message: string): Promise<void> {
await Fs.appendFile("logs", `${message}\n`); await Fs.appendFile("logs", `${message}\n`);
} }
// stats // stats
export function writeStatString(label: string, value: string): void { export function writeStatString(label: string, value: string): void {
console.log(`${label.padEnd(STAT_INDENT, " ")} ${Colors.bold.green(value)}`); console.log(
`${label.padEnd(STAT_INDENT, " ")} ${Colors.bold.green(value)}`,
);
} }
export function writeStatNumber(label: string, value: number): void { export function writeStatNumber(label: string, value: number): void {
console.log( console.log(
`${label.padEnd(STAT_INDENT, " ")} ${Colors.bold.blue(value.toString())}` `${label.padEnd(STAT_INDENT, " ")} ${Colors.bold.blue(value.toString())}`,
); );
} }
// logging // logging
export function writeError(message: any): void { export function writeError(message: any): void {
console.log(Colors.bold.red(message)); console.log(Colors.bold.red(message));
writeToLogFile(message); writeToLogFile(message);
} }
export function writeSuccess(message: string): void { export function writeSuccess(message: string): void {
console.log(Colors.bold.green(message)); console.log(Colors.bold.green(message));
writeToLogFile(message); writeToLogFile(message);
} }
// MESSAGES // MESSAGES
export function stringifyMessage(messageObject: WebSocketMessage): string { export function stringifyMessage(messageObject: WebSocketMessage): string {
return JSON.stringify(messageObject); return JSON.stringify(messageObject);
} }
export function parseMessage(messageString: string): Object { export function parseMessage(messageString: string): Object {
return JSON.parse(messageString); return JSON.parse(messageString);
} }
+182 -165
View File
@@ -1,10 +1,10 @@
import { Mailbox, Subscriber, SubscriptionMap } from "./subscriptionHandler"; import { Mailbox, Subscriber, SubscriptionMap } from "./subscriptionHandler";
import { import {
doIfIsString, doIfIsString,
parseMessage, parseMessage,
stringifyMessage, stringifyMessage,
writeError, writeError,
writeSuccess, writeSuccess,
} from "./utility"; } from "./utility";
import Crypto from "crypto"; import Crypto from "crypto";
@@ -13,27 +13,30 @@ import { defaultConfig } from "./config-handler";
// requests // requests
export interface WebSocketMessage { export interface WebSocketMessage {
uuid?: string; uuid?: string;
// connecting to server // connecting to server
requestingServerConnection?: boolean; requestingServerConnection?: boolean;
// mailbox // mailbox
requestingMailboxSetup?: boolean; requestingMailboxSetup?: boolean;
requestedMailbox?: string; requestedMailbox?: string;
deletingMailbox?: string; deletingMailbox?: string;
assignedMailboxId?: string; // sent by server assignedMailboxId?: string; // sent by server
connectedMailboxId?: string; // sent by server connectedMailboxId?: string; // sent by server
deletedMailbox?: string; // sent by server deletedMailbox?: string; // sent by server
// subscribing to channel // subscribing to channel
subscribeChannel?: string; subscribeChannel?: string;
unsubscribeChannel?: string; unsubscribeChannel?: string;
subscribed?: boolean; // sent by server subscribed?: boolean; // sent by server
//sending message //sending message
messageChannel?: string; messageChannel?: string;
messageBody?: string; messageBody?: string;
// confirming message
confirmingMessageReceived?: boolean;
} }
// TRACKING // TRACKING
@@ -49,219 +52,233 @@ export const serversConnected = new Set<WebSocket>();
export const serversDisconnected = new Set<string>(); export const serversDisconnected = new Set<string>();
export function getServerCount() { export function getServerCount() {
return serversConnected.size + serversConnectedAsClient.size; return serversConnected.size + serversConnectedAsClient.size;
} }
export function trackConnection(ws: WS) { export function trackConnection(ws: WS) {
clientsConnected.add(ws); clientsConnected.add(ws);
} }
export function forgetConnection(ws: WS): void { export function forgetConnection(ws: WS): void {
clientsConnected.delete(ws); clientsConnected.delete(ws);
serversConnectedAsClient.delete(ws); serversConnectedAsClient.delete(ws);
subscriptionMap.deleteSubscriber(ws); subscriptionMap.deleteSubscriber(ws);
} }
export function trackSubscription( export function trackSubscription(
subscriber: Subscriber, subscriber: Subscriber,
channel: string channel: string,
): void { ): void {
subscriptionMap.set(subscriber, channel); subscriptionMap.set(subscriber, channel);
confirmSubscription(subscriber, channel); confirmSubscription(subscriber, channel);
} }
export function forgetSubscription( export function forgetSubscription(
subscriber: Subscriber, subscriber: Subscriber,
channel: string channel: string,
): void { ): void {
subscriptionMap.delete(subscriber, channel); subscriptionMap.delete(subscriber, channel);
confirmSubscription(subscriber, channel); confirmSubscription(subscriber, channel);
} }
// AUDIT // AUDIT
export function getWebSocketStats(): [string, number][] { export function getWebSocketStats(): [string, number][] {
return [ return [
["channels", subscriptionMap.subscribersPerChannel.size], ["channels", subscriptionMap.subscribersPerChannel.size],
["clients connected", clientsConnected.size], ["clients connected", clientsConnected.size],
["mailboxes", mailboxes.size], ["mailboxes", mailboxes.size],
["servers connected", getServerCount()], ["servers connected", getServerCount()],
["servers disconnected", serversDisconnected.size], ["servers disconnected", serversDisconnected.size],
]; ];
} }
// OTHER SERVERS // OTHER SERVERS
export function connectServers(config: typeof defaultConfig): void { export function connectServers(config: typeof defaultConfig): void {
config.connectedServers.forEach((serverAddress) => { config.connectedServers.forEach((serverAddress) => {
connectServer(serverAddress, config); connectServer(serverAddress, config);
}); });
} }
export function reconnectServers(config: typeof defaultConfig): void { export function reconnectServers(config: typeof defaultConfig): void {
[...serversDisconnected.values()].forEach((address) => { [...serversDisconnected.values()].forEach((address) => {
connectServer(address, config); connectServer(address, config);
}); });
} }
export function connectServer( export function connectServer(
address: string, address: string,
config: typeof defaultConfig config: typeof defaultConfig,
): void { ): void {
try { try {
// connect // connect
const ws = new WebSocket(address); const ws = new WebSocket(address);
serversConnected.add(ws); serversConnected.add(ws);
// listen // listen
ws.addEventListener("open", () => { ws.addEventListener("open", () => {
serversDisconnected.delete(address); serversDisconnected.delete(address);
writeSuccess(`connected to server at "${address}".`); writeSuccess(`connected to server at "${address}".`);
subscribeChannels(ws, config); subscribeChannels(ws, config);
requestServerConnection(ws); requestServerConnection(ws);
}); });
ws.addEventListener("message", (message) => { ws.addEventListener("message", (message) => {
processMessage(ws, message.data.toString(), config); processMessage(ws, message.data.toString(), config);
}); });
ws.addEventListener("close", () => { ws.addEventListener("close", () => {
serversConnected.delete(ws); serversConnected.delete(ws);
serversDisconnected.add(address); serversDisconnected.add(address);
writeError(`server at "${address}" disconnected`); writeError(`server at "${address}" disconnected`);
}); });
} catch (error) { } catch (error) {
writeError(`failed to connect to server at "${address}": ${error}`); writeError(`failed to connect to server at "${address}": ${error}`);
} }
} }
function subscribeChannels(ws: WS, config: typeof defaultConfig): void { function subscribeChannels(ws: WS, config: typeof defaultConfig): void {
config.subscribedChannels.forEach((channel) => { config.subscribedChannels.forEach((channel) => {
subscribeChannel(ws, channel); subscribeChannel(ws, channel);
}); });
} }
export function subscribeChannel(server: WS, channel: string): void { export function subscribeChannel(server: WS, channel: string): void {
const message: WebSocketMessage = { const message: WebSocketMessage = {
subscribeChannel: channel, subscribeChannel: channel,
}; };
const messageString = stringifyMessage(message); const messageString = stringifyMessage(message);
server.send(messageString); server.send(messageString);
} }
// PROCESS MESSAGE // PROCESS MESSAGE
export function processMessage( export function processMessage(
ws: WS, ws: WS,
messageString: string, messageString: string,
config: typeof defaultConfig config: typeof defaultConfig,
): void { ): void {
// MESSAGE ID // MESSAGE ID
// check id // check id
const messageObject: WebSocketMessage = parseMessage(messageString); const messageObject: WebSocketMessage = parseMessage(messageString);
if (!messageObject.uuid) { if (!messageObject.uuid) {
// assign if no id // assign if no id
messageObject.uuid = Crypto.randomUUID(); messageObject.uuid = Crypto.randomUUID();
} else { } else if (messageObject.confirmingMessageReceived == true) {
// ignore if already seen // received confirmation
if (knownMessageIds.has(messageObject.uuid)) return; mailboxes.forEach((mailbox) => {
} if (mailbox.ws != ws) return;
// remember message return mailbox.checkMessageReceivedConfirmation(
knownMessageIds.add(messageObject.uuid); messageObject.uuid!,
);
});
} else {
// ignore if already seen
if (knownMessageIds.has(messageObject.uuid)) return;
}
// remember message
knownMessageIds.add(messageObject.uuid);
// SUBSCRIPTIONS // SUBSCRIPTIONS
doIfIsString(messageObject.subscribeChannel, (subscribeChannel) => { doIfIsString(messageObject.subscribeChannel, (subscribeChannel) => {
trackSubscription(ws, subscribeChannel); trackSubscription(ws, subscribeChannel);
}); });
doIfIsString(messageObject.unsubscribeChannel, (unsubscribeChannel) => { doIfIsString(messageObject.unsubscribeChannel, (unsubscribeChannel) => {
forgetSubscription(ws, unsubscribeChannel); forgetSubscription(ws, unsubscribeChannel);
}); });
// MESSAGE // MESSAGE
doIfIsString(messageObject.messageChannel, (messageChannel) => { doIfIsString(messageObject.messageChannel, (messageChannel) => {
if (messageObject.messageBody == undefined) return; if (messageObject.messageBody == undefined) return;
sendMessage(messageChannel, messageObject.messageBody, messageObject.uuid!); sendMessage(
}); messageChannel,
messageObject.messageBody,
messageObject.uuid!,
);
});
// MAILBOX // MAILBOX
if (messageObject.requestingMailboxSetup == true) { if (messageObject.requestingMailboxSetup == true) {
const mailbox = new Mailbox(ws); const mailbox = new Mailbox(ws);
mailboxes.set(mailbox.id, mailbox); mailboxes.set(mailbox.id, mailbox);
} }
doIfIsString(messageObject.requestedMailbox, (requestedMailbox) => { doIfIsString(messageObject.requestedMailbox, (requestedMailbox) => {
const mailbox = mailboxes.get(requestedMailbox); const mailbox = mailboxes.get(requestedMailbox);
if (!mailbox) if (!mailbox)
return sendWebSocketMessage( return sendWebSocketMessage(
{ deletedMailbox: requestedMailbox}, { deletedMailbox: requestedMailbox },
ws ws,
); );
mailbox.ws = ws; mailbox.ws = ws;
}); });
doIfIsString(messageObject.deletingMailbox, (deletingMailbox) => { doIfIsString(messageObject.deletingMailbox, (deletingMailbox) => {
const mailbox = mailboxes.get(deletingMailbox); const mailbox = mailboxes.get(deletingMailbox);
if (!mailbox) return; if (!mailbox) return;
removeMailbox(mailbox); removeMailbox(mailbox);
}); });
// OTHER SERVER // OTHER SERVER
if (messageObject.requestingServerConnection == true) { if (messageObject.requestingServerConnection == true) {
clientsConnected.delete(ws); clientsConnected.delete(ws);
serversConnectedAsClient.add(ws); serversConnectedAsClient.add(ws);
subscribeChannels(ws, config); subscribeChannels(ws, config);
} }
} }
// MAILBOX // MAILBOX
export function removeExpiredMailboxes() { export function removeExpiredMailboxes() {
mailboxes.forEach((mailbox) => { mailboxes.forEach((mailbox) => {
if (mailbox.isExpired) removeMailbox(mailbox); if (mailbox.isExpired) removeMailbox(mailbox);
}); });
} }
export function removeMailbox(mailbox: Mailbox) { export function removeMailbox(mailbox: Mailbox) {
mailbox.delete(); mailbox.delete();
mailboxes.delete(mailbox.id); mailboxes.delete(mailbox.id);
} }
// MESSAGING // MESSAGING
export function sendWebSocketMessage( export function sendWebSocketMessage(
messageObject: WebSocketMessage, messageObject: WebSocketMessage,
destination: Subscriber destination: Subscriber,
): void { ): void {
const messageString = JSON.stringify(messageObject); const messageString = JSON.stringify(messageObject);
destination.send(messageString); destination.send(messageString, false, messageObject.uuid);
} }
export function sendMessage( export function sendMessage(
messageChannel: string, messageChannel: string,
messageBody: string, messageBody: string,
uuid: string uuid: string,
): void { ): void {
const channels = messageChannel.split("/"); const channels = messageChannel.split("/");
subscriptionMap.forwardMessage(channels, { subscriptionMap.forwardMessage(channels, {
uuid, uuid,
messageChannel, messageChannel,
messageBody, messageBody,
}); });
} }
function requestServerConnection(ws: WS): void { function requestServerConnection(ws: WS): void {
const messageObject = { const messageObject = {
requestingServerConnection: true, requestingServerConnection: true,
}; };
const messageString = stringifyMessage(messageObject); const messageString = stringifyMessage(messageObject);
ws.send(messageString); ws.send(messageString);
} }
function confirmSubscription( function confirmSubscription(
subscriber: Subscriber, subscriber: Subscriber,
messageChannel: string messageChannel: string,
): void { ): void {
const messageObject: WebSocketMessage = { const messageObject: WebSocketMessage = {
messageChannel, messageChannel,
subscribed: subscriptionMap.getChannelList(subscriber)?.has(messageChannel) subscribed: subscriptionMap
? true .getChannelList(subscriber)
: false, ?.has(messageChannel)
}; ? true
const messageString = stringifyMessage(messageObject); : false,
subscriber.send(messageString); };
const messageString = stringifyMessage(messageObject);
subscriber.send(messageString);
} }