Compare commits

..

No commits in common. "cf13b689c749985746d4af74260313af4685c06e" and "d9cff2e70c4dab71d2a8780d58927f1f0dff2787" have entirely different histories.

7 changed files with 43 additions and 126 deletions

View file

@ -1,49 +0,0 @@
# HL7v2
HL7v2 is a medical communication protocol. This application tries to be a hands-on method for students to get to know
the communication foundation of almost every medical institution, typically hidden behind software forms.
## How it works
The application is web-based and features an individual number of clients. Every client has an input to construct a
raw HL7v2 message, build of the message header and an optional number of segments. Each editor has predefined segment
templates with missing values, which have to be filled in to be sent the message to a different client.
## Architecture
The stack features a SvelteKit application and a Bun websocket server. Communication between clients is only the
web-socket server, validating each message for a valid format. Only if the message was valid and delivered successfully
to a different client, the server sends back an acknowledgement and notifies the sending client.
# Getting started
1. Download the sources of the project
2. Copy the .env.example files to .env files
3. Execute `bun install` and `bun run dev` in the root directory
4. Start customizing the code to your needs
# Deployment
1. Download the compose.yaml file to the machine you want the application to run on.
2. Copy the sample .env file by running `cp .env.example .env` and adjust the new file to your needs.
```env
-- public URL of the client used by traefik
CLIENT_URL=my.app.com
-- public URL of the server used by the client to connect and traefik
SERVER_URL=server.my.app.com
-- the port the server listens on
PORT=8080
-- number of ID prefixes (no effect on pool-size)
PREFIXES=STA,LAB
-- amount of available IDs
POOL_SIZE=100
```
3. The compose.yaml file configures Traefik routers with TLS encryption. Customize the settings as needed or just remove
them if you are using a different reverse proxy.
4. Start up the containers using `podman|docker compose up -d`.

View file

@ -1,2 +0,0 @@
PUBLIC_SERVER=localhost:8080

View file

@ -16,16 +16,15 @@
import { Label } from '$lib/components/ui/dropdown-menu';
import { env } from '$env/dynamic/public';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '$lib/components/ui/tooltip';
import { dev } from '$app/environment';
// connection state
let ws = $state<WebSocket | undefined>(undefined); // websocket client
let stationId = $state<string | undefined>(undefined); // stationId assigned by server
let ws = $state<WebSocket | undefined>(undefined);
let userId = $state<string | undefined>(undefined);
let connectionState = $state<ConnectionState>(ConnectionState.disconnected);
// client state
let composedMessage = $state(''); // content of text-box
let sentMessages = $state<ReceiveHl7v2Message[]>([]); // sent messages stored as type ReceiveHl7v2Message because of the timestamp
let composedMessage = $state('');
let sentMessages = $state<ReceiveHl7v2Message[]>([]); // storing sent messages with client-timestamp for now
let receivedMessages = $state<ReceiveHl7v2Message[]>([]);
let isSending = $state(false);
let copySuccess = $state(false);
@ -60,33 +59,28 @@
};
const segmentTypes = Object.keys(segmentTemplates) as Array<keyof typeof segmentTemplates>;
// handles the connection process and message handling
function connectToServer() {
console.log('Connecting to server...');
connectionState = ConnectionState.connecting;
const socket = new WebSocket(`${dev ? 'ws' : 'wss'}://${env.PUBLIC_SERVER}`);
const socket = new WebSocket(`wss://${env.PUBLIC_SERVER}`);
// store websocket on successful connection
socket.onopen = () => {
console.log('WebSocket connection established.');
ws = socket;
};
// register message handlers
socket.onmessage = (event) => {
// parse every message as type Message
const message = JSON.parse(event.data) as Message;
console.log('Message received from server:', message);
// react based on message type
switch (message.type) {
// initial message from server assigning ID
case MessageType.assign_id:
stationId = message.payload.stationId;
userId = message.payload.userId;
composedMessage = segmentTemplates.MSH.template();
connectionState = ConnectionState.connected;
break;
@ -104,14 +98,12 @@
}
};
// reset connection state and websocket client on disconnect
socket.onclose = () => {
console.log('WebSocket connection closed.');
connectionState = ConnectionState.disconnected;
ws = undefined;
};
// reset connection state and websocket client on error
socket.onerror = (error) => {
console.error('WebSocket error:', error);
connectionState = ConnectionState.disconnected;
@ -119,7 +111,7 @@
};
}
// reset connection state and websocket client on tab closed
// clean up on close
$effect(() => {
return () => {
if (ws && ws.readyState === WebSocket.OPEN) {
@ -129,26 +121,21 @@
};
});
// adds the template of the provided segment type at the bottom of the textbox
function addSegment(type: keyof typeof segmentTemplates) {
const template = segmentTemplates[type].template();
composedMessage += `\r\n${template}`;
}
// handles sending a message to the websocket server
function handleSendMessage(message: string) {
// check for active connection
if (!ws || ws.readyState !== WebSocket.OPEN || isSending || !stationId) {
if (!ws || ws.readyState !== WebSocket.OPEN || isSending || !userId) {
console.log('Socket not ready');
return;
}
// set UI state
isSending = true;
deliveryError = '';
// construct Message object and send it
const messageToSend = {
type: 'send_hl7v2',
payload: { message },
@ -160,15 +147,12 @@
payload: { message, timestamp: new Date().toISOString() },
} as ReceiveHl7v2Message, ...sentMessages];
composedMessage = segmentTemplates.MSH.template();
// reset UI state
isSending = false;
}
// copies the stationId to the clipboard and handles UI state
function copyStationId() {
if (stationId) {
navigator.clipboard.writeText(stationId).then(() => {
function copyUserId() {
if (userId) {
navigator.clipboard.writeText(userId).then(() => {
copySuccess = true;
setTimeout(() => copySuccess = false, 2000);
});
@ -178,7 +162,6 @@
</script>
<!-- 'component' for displaying a message in the message lists (sent and received) -->
{#snippet message(msg: ReceiveHl7v2Message)}
<div class="bg-foreground/10 p-3 rounded-md space-y-1">
<p class="text-xs">{new Date(msg.payload.timestamp).toLocaleString()}</p>
@ -191,7 +174,7 @@
<div class="max-w-7xl mx-auto">
<main class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left Column: Station Info & Logs -->
<!-- Left Column: User Info & Logs -->
<div class="space-y-6">
<Card>
<CardHeader class="flex flex-row items-center">
@ -199,11 +182,11 @@
Your Info
</CardHeader>
<CardContent>
{#if connectionState === ConnectionState.connected && stationId}
{#if connectionState === ConnectionState.connected && userId}
<Label class="">Station ID</Label>
<div class="flex items-center space-x-2">
<Input disabled bind:value={stationId}/>
<Button variant="outline" size="icon" onclick={copyStationId}>
<Input disabled bind:value={userId}/>
<Button variant="outline" size="icon" onclick={copyUserId}>
{#if copySuccess}
<CheckIcon class={'text-green-400'}/>
{:else}
@ -249,7 +232,7 @@
<span>HL7v2 Message Editor</span>
</CardHeader>
<CardContent>
{#if connectionState === ConnectionState.connected && stationId}
{#if connectionState === ConnectionState.connected && userId}
<div>
<div class="flex flex-wrap gap-2 mb-4">
{#each segmentTypes as type (type)}
@ -312,4 +295,4 @@
</div>
</main>
</div>
</div>
</div>

View file

@ -1,4 +0,0 @@
PORT=8080
PREFIXES=STA,LAB
POOL_SIZE=100

View file

@ -1,25 +1,23 @@
// define available configurations
interface Config {
port: number;
prefixes: string[];
poolSize: number;
}
// construct config object from environment variables
export const config: Config = {
port: parseInt(process.env.PORT || '8080', 10),
prefixes: (process.env.PREFIXES || 'STA').split(","),
poolSize: parseInt(process.env.POOL_SIZE || '100', 10),
};
// validate config
if (isNaN(config.port) || config.port < 1024 || config.port > 49151) {
throw new Error('Invalid PORT environment variable (1024 - 49151)');
}
if (config.prefixes.length === 0) {
throw new Error('Invalid PREFIXES environment variable (length > 1)');
}
if (isNaN(config.poolSize) || config.poolSize < config.prefixes.length) {
throw new Error('Invalid pool size environment variable (poolSize >= prefix.length)');
throw new Error('Invalid PORT environment variable (poolSize >= prefix.length)');
}

View file

@ -17,7 +17,6 @@ for (const prefix of config.prefixes) {
}
}
// mixes the array randomly to avoid getting the same ID reassigned
function shake(arr: any[]) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
@ -50,41 +49,40 @@ wss.on('connection', (ws) => {
return;
}
// get ID from pool and assign to station
const stationId = availableIds.pop();
if (!stationId) {
// get ID from pool and assign to user
const userId = availableIds.pop();
if (!userId) {
console.log('Connection rejected: Failed to retrieve ID.');
ws.close(1013, 'Server is full. Please try again later.'); // 1013: Try again later
return;
}
// store client in map
clients.set(stationId, ws);
clients.set(userId, ws);
// send station ID to client
const welcomeMessage = {
type: 'assign_id',
payload: {
stationId: stationId,
},
} as Message;
ws.send(JSON.stringify(welcomeMessage));
setTimeout(() => {
// send user ID to client
const welcomeMessage = {
type: 'assign_id',
payload: {
userId: userId,
},
} as Message;
ws.send(JSON.stringify(welcomeMessage));
console.log(`Client connected. Assigning ID: ${stationId}`);
console.log(`Client connected. Assigning ID: ${userId}`);
}, 0);
// listen for messages and defined message handling
ws.on('message', (message) => {
try {
// parse every message to type Message
const parsedMessage: Message = JSON.parse(message.toString());
console.log(`Received message from ${stationId}:`, parsedMessage);
console.log(`Received message from ${userId}:`, parsedMessage);
// We only expect one type of message from clients: 'send_hl7v2'
if (parsedMessage.type === MessageType.send_hl7v2) {
const { message } = parsedMessage.payload;
// TODO: validate message
// get sender and recipient ID
@ -102,7 +100,6 @@ wss.on('connection', (ws) => {
// Find the recipient's WebSocket connection in our map.
const recipientWs = clients.get(recipientId);
// check if recipient exists and is connected
if (recipientWs && recipientWs.readyState === WebSocket.OPEN) {
// The recipient is connected. Forward the message.
@ -130,23 +127,22 @@ wss.on('connection', (ws) => {
}
}
} catch (error) {
console.error(`Failed to process message from ${stationId}:`, error);
console.error(`Failed to process message from ${userId}:`, error);
}
});
// listen to disconnects to free IDs
ws.on('close', () => {
console.log(`Client ${stationId} disconnected.`);
clients.delete(stationId);
if (stationId) {
availableIds.push(stationId);
console.log(`Client ${userId} disconnected.`);
clients.delete(userId);
if (userId) {
availableIds.push(userId);
}
// shake IDs to avoid getting the same ID reassigned
availableIds = shake(availableIds);
});
ws.on('error', (error) => {
console.error(`WebSocket error for client ${stationId}:`, error);
console.error(`WebSocket error for client ${userId}:`, error);
});
});

View file

@ -1,12 +1,9 @@
// defines different connection states
export enum ConnectionState {
connecting = 'connecting',
connected = 'connected',
disconnected = 'disconnected',
}
// defines message types
export enum MessageType {
assign_id = 'assign_id',
send_hl7v2 = 'send_hl7v2',
@ -14,12 +11,10 @@ export enum MessageType {
delivery_error = 'delivery_error',
}
// defines body of message types
export type Message =
| { type: MessageType.assign_id, payload: { stationId: string }}
| { type: MessageType.assign_id, payload: { userId: string }}
| { type: MessageType.send_hl7v2, payload: { message: string }}
| { type: MessageType.receive_hl7v2, payload: { message: string, timestamp: string }}
| { type: MessageType.delivery_error, payload: { error: string }}
// required to define list of this message type
export type ReceiveHl7v2Message = Extract<Message, { type: MessageType.receive_hl7v2 }>;