Compare commits
6 commits
d9cff2e70c
...
cf13b689c7
Author | SHA1 | Date | |
---|---|---|---|
cf13b689c7 | |||
3898715915 | |||
ad48df97e5 | |||
1402b4f1ed | |||
bb3a6a5d8e | |||
3e00cf88e7 |
7 changed files with 126 additions and 43 deletions
49
README.md
Normal file
49
README.md
Normal 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`.
|
2
packages/client/.env.example
Normal file
2
packages/client/.env.example
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
|
||||||
|
PUBLIC_SERVER=localhost:8080
|
|
@ -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)}
|
||||||
|
@ -295,4 +312,4 @@
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
4
packages/server/.env.example
Normal file
4
packages/server/.env.example
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
|
||||||
|
PORT=8080
|
||||||
|
PREFIXES=STA,LAB
|
||||||
|
POOL_SIZE=100
|
|
@ -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)');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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 }>;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue