Initial commit

This commit is contained in:
Markus Thielker 2025-07-24 15:08:39 +02:00
commit d9cff2e70c
72 changed files with 2878 additions and 0 deletions

34
packages/server/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View file

@ -0,0 +1,61 @@
# Stage 1: Builder
# This stage installs all dependencies and builds all packages.
FROM oven/bun:1 AS builder
WORKDIR /app
# Copy dependency manifests
COPY package.json bun.lock ./
COPY tsconfig.base.json ./
# Copy package-specific manifests to leverage Docker cache
COPY packages/shared/package.json packages/shared/tsconfig.json ./packages/shared/
COPY packages/server/package.json packages/server/tsconfig.json ./packages/server/
# Install ALL workspace dependencies
RUN bun install
# Copy source code
COPY packages/shared ./packages/shared/
COPY packages/server ./packages/server/
# Build the server and its dependencies (i.e., 'shared')
RUN bun run --filter=@hnu.de/hl7v2-shared build
RUN bun run --filter=@hnu.de/hl7v2-server build
#---------------------------------------------------------------------
# Stage 2: Production
FROM oven/bun:1-slim
WORKDIR /app
# Create the full directory structure first
RUN mkdir -p packages/shared packages/server
# Copy workspace configuration
COPY --from=builder /app/package.json /app/bun.lock ./
# Set up shared package with its dist directory
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
# Set up server package with its dist directory
COPY --from=builder /app/packages/server/package.json ./packages/server/
COPY --from=builder /app/packages/server/dist ./packages/server/dist
# Install ONLY production dependencies
ENV NODE_ENV=production
RUN bun install \
--frozen-lockfile \
--production
# Set environment variables from config.ts
ENV PORT=8080 \
PREFIXES=STA \
POOL_SIZE=100
EXPOSE 8080
# Run the server from its package directory
WORKDIR /app/packages/server
CMD ["bun", "run", "dist/index.js"]

23
packages/server/config.ts Normal file
View file

@ -0,0 +1,23 @@
interface Config {
port: number;
prefixes: string[];
poolSize: number;
}
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),
};
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 PORT environment variable (poolSize >= prefix.length)');
}

160
packages/server/index.ts Normal file
View file

@ -0,0 +1,160 @@
import { WebSocketServer } from 'ws';
import { type Message, MessageType } from '@hnu.de/hl7v2-shared';
import 'dotenv/config';
import { config } from './config';
// ###########################################################################
// ### ID Pool
// ###########################################################################
let availableIds: string[] = [];
for (const prefix of config.prefixes) {
for (let i = 1; i <= config.poolSize / config.prefixes.length; i++) {
const number = i.toString().padStart(config.poolSize.toString().length, '0');
availableIds.push(`${prefix}-${number}`);
}
}
function shake(arr: any[]) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
availableIds = shake(availableIds);
// ###########################################################################
// ### Websocket Server
// ###########################################################################
const wss = new WebSocketServer({ port: config.port });
const clients = new Map();
console.log(`Starting WebSocket server ...`);
console.log("Server configuration:", config);
wss.on('connection', (ws) => {
// check for available IDs
if (availableIds.length === 0) {
console.log('Connection rejected: No available IDs.');
ws.close(1013, 'Server is full. Please try again later.'); // 1013: Try again later
return;
}
// 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(userId, ws);
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: ${userId}`);
}, 0);
ws.on('message', (message) => {
try {
const parsedMessage: Message = JSON.parse(message.toString());
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
const recipientId = parseMshField(message, 5);
if (!recipientId) {
const errorMessage = {
type: 'delivery_error',
payload: {
error: `Message is missing header field 5.`,
},
} as Message;
ws.send(JSON.stringify(errorMessage));
}
// Find the recipient's WebSocket connection in our map.
const recipientWs = clients.get(recipientId);
if (recipientWs && recipientWs.readyState === WebSocket.OPEN) {
// The recipient is connected. Forward the message.
const forwardMessage = {
type: MessageType.receive_hl7v2,
payload: {
message: message,
timestamp: new Date().toISOString(),
},
} as Message;
recipientWs.send(JSON.stringify(forwardMessage));
console.log(`Forwarded message to ${recipientId}`);
} else {
// The recipient is not connected or not found.
console.log(`Recipient ${recipientId} not found or not connected.`);
const errorMessage = {
type: MessageType.delivery_error,
payload: {
error: `Station with ID ${recipientId} is not available.`,
},
} as Message;
ws.send(JSON.stringify(errorMessage));
}
}
} catch (error) {
console.error(`Failed to process message from ${userId}:`, error);
}
});
// listen to disconnects to free IDs
ws.on('close', () => {
console.log(`Client ${userId} disconnected.`);
clients.delete(userId);
if (userId) {
availableIds.push(userId);
}
availableIds = shake(availableIds);
});
ws.on('error', (error) => {
console.error(`WebSocket error for client ${userId}:`, error);
});
});
function parseMshField(message: string, fieldIndex: number) {
try {
const mshLine = message.split(/[\r\n]+/)[0];
if (!mshLine || !mshLine.startsWith('MSH')) return null;
const fields = mshLine.split('|');
return fields[fieldIndex] || null;
} catch (e) {
return null;
}
}
console.log(`WebSocket server started on port ${config.port}`);

View file

@ -0,0 +1,21 @@
{
"name": "@hnu.de/hl7v2-server",
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"build": "tsc",
"dev": "bun --watch index.ts --watch config.ts",
"start": "bun index.ts"
},
"devDependencies": {
"@types/bun": "latest",
"@types/ws": "^8.18.1",
"typescript": "^5.8.3",
},
"dependencies": {
"@hnu.de/hl7v2-shared": "workspace:*",
"dotenv": "^17.2.0",
"ws": "^8.18.3"
}
}

View file

@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true
},
"include": [
"*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}