Compare commits

...

6 commits

7 changed files with 126 additions and 43 deletions

49
README.md Normal file
View file

@ -0,0 +1,49 @@
# 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

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

View file

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

View file

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

View file

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

View file

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

View file

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