@medplum/agent 2.0.29 → 2.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/installer.nsi ADDED
@@ -0,0 +1,199 @@
1
+ # Medplum Agent Installer Builder
2
+ # For use with NSIS 3.0+
3
+ # See: https://nsis.sourceforge.io/
4
+
5
+ !define COMPANY_NAME "Medplum"
6
+ !define APP_NAME "Medplum Agent"
7
+ !define SERVICE_NAME "MedplumAgent"
8
+ !define INSTALLER_FILE_NAME "medplum-agent-installer.exe"
9
+ !define DEFAULT_BASE_URL "https://api.medplum.com/"
10
+
11
+ Name "${APP_NAME}"
12
+ OutFile "${INSTALLER_FILE_NAME}"
13
+ VIProductVersion "1.0.0.0"
14
+ VIAddVersionKey ProductName "${APP_NAME}"
15
+ VIAddVersionKey Comments "${APP_NAME}"
16
+ VIAddVersionKey CompanyName "${COMPANY_NAME}"
17
+ VIAddVersionKey LegalCopyright "${COMPANY_NAME}"
18
+ VIAddVersionKey FileDescription "${APP_NAME}"
19
+ VIAddVersionKey FileVersion 1
20
+ VIAddVersionKey ProductVersion 1
21
+ VIAddVersionKey InternalName "${APP_NAME}"
22
+ VIAddVersionKey LegalTrademarks "${COMPANY_NAME}"
23
+ VIAddVersionKey OriginalFilename "${INSTALLER_FILE_NAME}"
24
+
25
+ InstallDir "$PROGRAMFILES64\${APP_NAME}"
26
+
27
+ !include "nsDialogs.nsh"
28
+
29
+ RequestExecutionLevel admin
30
+
31
+ Var WelcomeDialog
32
+ Var WelcomeLabel
33
+ Var baseUrl
34
+ Var clientId
35
+ Var clientSecret
36
+ Var agentId
37
+
38
+ Page custom WelcomePage
39
+ Page custom InputPage InputPageLeave
40
+ Page instfiles
41
+
42
+ # The WelcomePage is a simple static screen that displays a friendly message.
43
+ Function WelcomePage
44
+ nsDialogs::Create 1018
45
+ Pop $WelcomeDialog
46
+
47
+ ${If} $WelcomeDialog == error
48
+ Abort
49
+ ${EndIf}
50
+
51
+ ${NSD_CreateLabel} 0 0 100% 50u "Welcome to the ${APP_NAME} Installer!$\r$\n$\r$\nClick next to continue."
52
+ Pop $WelcomeLabel
53
+
54
+ nsDialogs::Show
55
+ FunctionEnd
56
+
57
+ # The InputPage captures all of the user input for the agent.
58
+ Function InputPage
59
+ nsDialogs::Create 1018
60
+ Pop $0
61
+
62
+ StrCpy $baseUrl "${DEFAULT_BASE_URL}"
63
+ ${NSD_CreateLabel} 0 0 30% 12u "Base URL:"
64
+ Pop $R0
65
+ ${NSD_CreateText} 35% 0 65% 12u $baseUrl
66
+ Pop $R1
67
+
68
+ ${NSD_CreateLabel} 0 15u 30% 12u "Client ID:"
69
+ Pop $R2
70
+ ${NSD_CreateText} 35% 15u 65% 12u $clientId
71
+ Pop $R3
72
+
73
+ ${NSD_CreateLabel} 0 30u 30% 12u "Client Secret:"
74
+ Pop $R4
75
+ ${NSD_CreateText} 35% 30u 65% 12u $clientSecret
76
+ Pop $R5
77
+
78
+ ${NSD_CreateLabel} 0 45u 30% 12u "Agent ID:"
79
+ Pop $R6
80
+ ${NSD_CreateText} 35% 45u 65% 12u $agentId
81
+ Pop $R7
82
+
83
+ ${NSD_SetFocus} $R3
84
+ nsDialogs::Show
85
+ FunctionEnd
86
+
87
+ Function InputPageLeave
88
+ ${NSD_GetText} $R1 $baseUrl
89
+ ${NSD_GetText} $R3 $clientId
90
+ ${NSD_GetText} $R5 $clientSecret
91
+ ${NSD_GetText} $R7 $agentId
92
+ FunctionEnd
93
+
94
+ # Do the actual installation.
95
+ # Install all of the files.
96
+ # Install the Windows Service.
97
+ Section
98
+ DetailPrint "${APP_NAME}"
99
+
100
+ # Call userInfo plugin to get user info. The plugin puts the result in the stack
101
+ userInfo::getAccountType
102
+
103
+ # Pop the result from the stack into $0
104
+ Pop $0
105
+
106
+ # Compare the result with the string "Admin" to see if the user is admin.
107
+ # If match, jump 3 lines down.
108
+ strCmp $0 "Admin" +3
109
+
110
+ # If there is not a match, print message and return
111
+ DetailPrint "User is not admin: $0"
112
+ return
113
+
114
+ # Otherwise, confirm and return
115
+ DetailPrint "User is admin"
116
+
117
+ # Print user input
118
+ DetailPrint "Base URL: $baseUrl"
119
+ DetailPrint "Client ID: $clientId"
120
+ DetailPrint "Client Secret: $clientSecret"
121
+ DetailPrint "Agent ID: $agentId"
122
+
123
+ # Copy the service files to the root directory
124
+ SetOutPath "$INSTDIR"
125
+ File ..\..\node_modules\node-windows\bin\winsw\winsw.exe
126
+ File dist\medplum-agent-win-x64.exe
127
+ File README.md
128
+
129
+ # Create the winsw.xml config file
130
+ # See config file format: https://github.com/winsw/winsw/blob/v3/docs/xml-config-file.md
131
+ FileOpen $9 winsw.xml w
132
+ FileWrite $9 "<service>$\r$\n"
133
+ FileWrite $9 "<id>${SERVICE_NAME}</id>$\r$\n"
134
+ FileWrite $9 "<name>${APP_NAME}</name>$\r$\n"
135
+ FileWrite $9 "<description>Securely connects local devices to ${COMPANY_NAME} cloud</description>$\r$\n"
136
+ FileWrite $9 "<executable>$INSTDIR\medplum-agent-win-x64.exe</executable>$\r$\n"
137
+ FileWrite $9 "<arguments>$\"$baseUrl$\" $\"$clientId$\" $\"$clientSecret$\" $\"$agentId$\"</arguments>$\r$\n"
138
+ FileWrite $9 "<startmode>Automatic</startmode>$\r$\n"
139
+ FileWrite $9 "</service>$\r$\n"
140
+ FileClose $9
141
+
142
+ # Install the service
143
+ DetailPrint "Installing service..."
144
+ StrCpy $0 "winsw.exe install"
145
+ #DetailPrint "$0"
146
+ ExecWait $0 $1
147
+ DetailPrint "Install returned $1"
148
+
149
+ # Start the service
150
+ DetailPrint "Starting service..."
151
+ StrCpy $0 "winsw.exe start"
152
+ #DetailPrint "$0"
153
+ ExecWait $0 $1
154
+ DetailPrint "Start service returned $1"
155
+
156
+ # Create the uninstaller
157
+ DetailPrint "Creating the uninstaller..."
158
+ SetOutPath $INSTDIR
159
+ WriteUninstaller "$INSTDIR\uninstall.exe"
160
+
161
+ # Register the uninstaller
162
+ DetailPrint "Registering the uninstaller..."
163
+ WriteRegStr HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${SERVICE_NAME}" "DisplayName" "${APP_NAME}"
164
+ WriteRegStr HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${SERVICE_NAME}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
165
+ WriteRegStr HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${SERVICE_NAME}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
166
+ DetailPrint "Uninstaller complete"
167
+
168
+ # Create Start menu shortcuts
169
+ DetailPrint "$SMPROGRAMS\${APP_NAME}\${APP_NAME} Uninstall.lnk"
170
+ CreateDirectory "$SMPROGRAMS\${APP_NAME}"
171
+ CreateShortCut "$SMPROGRAMS\${APP_NAME}\${APP_NAME} Uninstall.lnk" "$INSTDIR\uninstall.exe"
172
+
173
+ # default section end
174
+ SectionEnd
175
+
176
+ # Start the uninstaller
177
+ Section Uninstall
178
+
179
+ # Uninstall the service
180
+ DetailPrint "Uninstalling service..."
181
+ SetOutPath "$INSTDIR"
182
+ StrCpy $0 "winsw.exe uninstall"
183
+ #DetailPrint "$0"
184
+ ExecWait $0 $1
185
+ DetailPrint "Uninstall returned $1"
186
+
187
+ # Get out of the service directory so we can delete it
188
+ SetOutPath "$PROGRAMFILES64"
189
+
190
+ # Uninstall the Start menu shortcuts
191
+ RMDir /r /REBOOTOK "$SMPROGRAMS\${APP_NAME}"
192
+
193
+ # Delete the files
194
+ RMDir /r /REBOOTOK "$INSTDIR"
195
+
196
+ # Unregister the program
197
+ DeleteRegKey HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${SERVICE_NAME}"
198
+
199
+ SectionEnd
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medplum/agent",
3
- "version": "2.0.29",
3
+ "version": "2.0.31",
4
4
  "description": "Medplum Agent",
5
5
  "author": "Medplum <hello@medplum.com>",
6
6
  "license": "Apache-2.0",
@@ -17,17 +17,22 @@
17
17
  "clean": "rimraf dist",
18
18
  "build": "npm run clean && tsc && node esbuild.mjs",
19
19
  "test": "jest",
20
- "package": "pkg ./dist/cjs/index.cjs --targets node18-win-x64 --output dist/medplum-agent-win-x64.exe"
20
+ "agent": "ts-node src/main.ts",
21
+ "package": "pkg ./dist/cjs/index.cjs --targets node18-win-x64 --output dist/medplum-agent-win-x64.exe --options no-warnings",
22
+ "installer": "makensis installer.nsi"
21
23
  },
22
24
  "dependencies": {
23
25
  "@medplum/core": "*",
24
26
  "@medplum/hl7": "*",
25
- "node-windows": "1.0.0-beta.8"
27
+ "node-windows": "1.0.0-beta.8",
28
+ "ws": "8.13.0"
26
29
  },
27
30
  "devDependencies": {
28
31
  "@medplum/fhirtypes": "*",
29
32
  "@medplum/mock": "*",
30
33
  "@types/node-windows": "0.1.2",
34
+ "@types/ws": "8.5.5",
35
+ "mock-socket": "9.2.1",
31
36
  "pkg": "5.8.1"
32
37
  }
33
38
  }
@@ -0,0 +1 @@
1
+ export { WebSocket as default } from 'mock-socket';
package/src/main.test.ts CHANGED
@@ -1,13 +1,15 @@
1
- import { allOk, Hl7Message } from '@medplum/core';
2
- import { Bot, Resource } from '@medplum/fhirtypes';
1
+ import { allOk, createReference, Hl7Message } from '@medplum/core';
2
+ import { Agent, Bot, Endpoint, Resource } from '@medplum/fhirtypes';
3
3
  import { Hl7Client } from '@medplum/hl7';
4
4
  import { MockClient } from '@medplum/mock';
5
+ import { Server } from 'mock-socket';
5
6
  import { App } from './main';
6
7
 
7
8
  jest.mock('node-windows');
8
9
 
9
10
  const medplum = new MockClient();
10
11
  let bot: Bot;
12
+ let endpoint: Endpoint;
11
13
 
12
14
  describe('Agent', () => {
13
15
  beforeAll(async () => {
@@ -18,17 +20,73 @@ describe('Agent', () => {
18
20
  });
19
21
 
20
22
  bot = await medplum.createResource<Bot>({ resourceType: 'Bot' });
23
+
24
+ endpoint = await medplum.createResource<Endpoint>({
25
+ resourceType: 'Endpoint',
26
+ address: 'mllp://0.0.0.0:56000',
27
+ });
21
28
  });
22
29
 
23
30
  test('Runs successfully', async () => {
24
- const app = new App(medplum, bot);
25
- app.start();
31
+ const agent = await medplum.createResource<Agent>({
32
+ resourceType: 'Agent',
33
+ channel: [
34
+ {
35
+ endpoint: createReference(endpoint),
36
+ targetReference: createReference(bot),
37
+ },
38
+ ],
39
+ });
40
+
41
+ const app = new App(medplum, agent.id as string);
42
+ await app.start();
43
+ app.stop();
26
44
  app.stop();
27
45
  });
28
46
 
29
47
  test('Send and receive', async () => {
30
- const app = new App(medplum, bot);
31
- app.start();
48
+ const mockServer = new Server('wss://example.com/ws/agent');
49
+
50
+ mockServer.on('connection', (socket) => {
51
+ socket.on('message', (data) => {
52
+ const command = JSON.parse((data as Buffer).toString('utf8'));
53
+ if (command.type === 'connect') {
54
+ socket.send(
55
+ Buffer.from(
56
+ JSON.stringify({
57
+ type: 'connected',
58
+ })
59
+ )
60
+ );
61
+ }
62
+
63
+ if (command.type === 'transmit') {
64
+ const hl7Message = Hl7Message.parse(command.message);
65
+ const ackMessage = hl7Message.buildAck();
66
+ socket.send(
67
+ Buffer.from(
68
+ JSON.stringify({
69
+ type: 'transmit',
70
+ message: ackMessage.toString(),
71
+ })
72
+ )
73
+ );
74
+ }
75
+ });
76
+ });
77
+
78
+ const agent = await medplum.createResource<Agent>({
79
+ resourceType: 'Agent',
80
+ channel: [
81
+ {
82
+ endpoint: createReference(endpoint),
83
+ targetReference: createReference(bot),
84
+ },
85
+ ],
86
+ });
87
+
88
+ const app = new App(medplum, agent.id as string);
89
+ await app.start();
32
90
 
33
91
  const client = new Hl7Client({
34
92
  host: 'localhost',
@@ -46,8 +104,12 @@ describe('Agent', () => {
46
104
  )
47
105
  );
48
106
  expect(response).toBeDefined();
107
+ expect(response.header.getComponent(9, 1)).toBe('ACK');
108
+ expect(response.segments).toHaveLength(2);
109
+ expect(response.segments[1].name).toBe('MSA');
49
110
 
50
111
  client.close();
51
112
  app.stop();
113
+ mockServer.stop();
52
114
  });
53
115
  });
package/src/main.ts CHANGED
@@ -1,61 +1,188 @@
1
- import { ContentType, MedplumClient, normalizeErrorString } from '@medplum/core';
2
- import { Bot } from '@medplum/fhirtypes';
3
- import { Hl7MessageEvent, Hl7Server } from '@medplum/hl7';
1
+ import { Hl7Message, MedplumClient, resolveId } from '@medplum/core';
2
+ import { AgentChannel, Bot, Endpoint, Reference } from '@medplum/fhirtypes';
3
+ import { Hl7Connection, Hl7MessageEvent, Hl7Server } from '@medplum/hl7';
4
4
  import { EventLogger } from 'node-windows';
5
-
6
- const log = new EventLogger({
7
- source: 'MedplumService',
8
- eventLog: 'SYSTEM',
9
- });
5
+ import WebSocket from 'ws';
10
6
 
11
7
  export class App {
12
8
  readonly log: EventLogger;
13
- readonly server: Hl7Server;
9
+ readonly channels: AgentHl7Channel[];
14
10
 
15
- constructor(readonly medplum: MedplumClient, readonly bot: Bot) {
16
- this.log = new EventLogger({
17
- source: 'MedplumService',
18
- eventLog: 'SYSTEM',
19
- });
11
+ constructor(
12
+ readonly medplum: MedplumClient,
13
+ readonly agentId: string
14
+ ) {
15
+ this.log = {
16
+ info: console.log,
17
+ warn: console.warn,
18
+ error: console.error,
19
+ } as EventLogger;
20
20
 
21
- this.server = new Hl7Server();
22
- this.server.addEventListener('message', (event) => this.handler(event));
21
+ this.channels = [];
23
22
  }
24
23
 
25
- start(): void {
24
+ async start(): Promise<void> {
26
25
  this.log.info('Medplum service starting...');
27
- this.server.start(56000);
26
+
27
+ const agent = await this.medplum.readResource('Agent', this.agentId);
28
+
29
+ for (const definition of agent.channel as AgentChannel[]) {
30
+ const endpoint = await this.medplum.readReference(definition.endpoint as Reference<Endpoint>);
31
+ const channel = new AgentHl7Channel(this, definition, endpoint);
32
+ channel.start();
33
+ this.channels.push(channel);
34
+ }
35
+
28
36
  this.log.info('Medplum service started successfully');
29
37
  }
30
38
 
31
39
  stop(): void {
32
40
  this.log.info('Medplum service stopping...');
33
- this.server.stop();
41
+ this.channels.forEach((channel) => channel.stop());
34
42
  this.log.info('Medplum service stopped successfully');
35
43
  }
44
+ }
45
+
46
+ export class AgentHl7Channel {
47
+ readonly server: Hl7Server;
48
+ readonly connections: AgentHl7ChannelConnection[] = [];
49
+
50
+ constructor(
51
+ readonly app: App,
52
+ readonly definition: AgentChannel,
53
+ readonly endpoint: Endpoint
54
+ ) {
55
+ this.server = new Hl7Server((connection) => {
56
+ this.app.log.info('HL7 connection established');
57
+ this.connections.push(new AgentHl7ChannelConnection(this, connection));
58
+ });
59
+ }
60
+
61
+ start(): void {
62
+ const address = new URL(this.endpoint.address as string);
63
+ this.app.log.info(`Channel starting on ${address}`);
64
+ this.server.start(parseInt(address.port, 10));
65
+ this.app.log.info('Channel started successfully');
66
+ }
67
+
68
+ stop(): void {
69
+ this.app.log.info('Channel stopping...');
70
+ for (const connection of this.connections) {
71
+ connection.close();
72
+ }
73
+ this.server.stop();
74
+ this.app.log.info('Channel stopped successfully');
75
+ }
76
+ }
77
+
78
+ export class AgentHl7ChannelConnection {
79
+ readonly webSocket: WebSocket;
80
+ readonly webSocketQueue: Hl7Message[] = [];
81
+ readonly hl7ConnectionQueue: Hl7Message[] = [];
82
+ live = false;
83
+
84
+ constructor(
85
+ readonly channel: AgentHl7Channel,
86
+ readonly hl7Connection: Hl7Connection
87
+ ) {
88
+ const app = channel.app;
89
+ const medplum = app.medplum;
90
+
91
+ // Add listener immediately to handle incoming messages
92
+ this.hl7Connection.addEventListener('message', (event) => this.handler(event));
93
+
94
+ const webSocketUrl = new URL(medplum.getBaseUrl());
95
+ webSocketUrl.protocol = webSocketUrl.protocol === 'https:' ? 'wss:' : 'ws:';
96
+ webSocketUrl.pathname = '/ws/agent';
97
+ console.log('Connecting to WebSocket:', webSocketUrl.href);
98
+
99
+ this.webSocket = new WebSocket(webSocketUrl);
100
+ this.webSocket.binaryType = 'nodebuffer';
101
+ this.webSocket.addEventListener('error', console.error);
102
+ this.webSocket.addEventListener('open', () => {
103
+ this.webSocket.send(
104
+ JSON.stringify({
105
+ type: 'connect',
106
+ accessToken: medplum.getAccessToken(),
107
+ botId: resolveId(channel.definition.targetReference as Reference<Bot>),
108
+ })
109
+ );
110
+ });
111
+
112
+ this.webSocket.addEventListener('message', (e) => {
113
+ try {
114
+ const data = e.data as Buffer;
115
+ const str = data.toString('utf8');
116
+ console.log('Received from WebSocket:', str.replaceAll('\r', '\n'));
117
+ const command = JSON.parse(str);
118
+ switch (command.type) {
119
+ case 'connected':
120
+ this.live = true;
121
+ this.trySendToWebSocket();
122
+ break;
123
+ case 'transmit':
124
+ this.hl7ConnectionQueue.push(Hl7Message.parse(command.message));
125
+ this.trySendToHl7Connection();
126
+ break;
127
+ }
128
+ } catch (err) {
129
+ console.log('WebSocket error', err);
130
+ }
131
+ });
132
+ }
36
133
 
37
134
  private async handler(event: Hl7MessageEvent): Promise<void> {
38
135
  try {
39
136
  console.log('Received:');
40
137
  console.log(event.message.toString().replaceAll('\r', '\n'));
41
-
42
- await this.medplum.post(
43
- this.medplum.fhirUrl('Bot', this.bot.id as string, '$execute'),
44
- event.message.toString(),
45
- ContentType.HL7_V2
46
- );
47
-
48
- const ack = event.message.buildAck();
49
- console.log('Response:');
50
- console.log(ack.toString().replaceAll('\r', '\n'));
51
- event.send(ack);
138
+ this.webSocketQueue.push(event.message);
139
+ this.trySendToWebSocket();
52
140
  } catch (err) {
53
141
  console.log('HL7 error', err);
54
- log.error(normalizeErrorString(err));
55
142
  }
56
143
  }
144
+
145
+ private trySendToWebSocket(): void {
146
+ if (this.live) {
147
+ while (this.webSocketQueue.length > 0) {
148
+ const msg = this.webSocketQueue.shift();
149
+ if (msg) {
150
+ this.webSocket.send(
151
+ JSON.stringify({
152
+ type: 'transmit',
153
+ message: msg.toString(),
154
+ })
155
+ );
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ private trySendToHl7Connection(): void {
162
+ while (this.hl7ConnectionQueue.length > 0) {
163
+ const msg = this.hl7ConnectionQueue.shift();
164
+ if (msg) {
165
+ this.hl7Connection.send(msg);
166
+ }
167
+ }
168
+ }
169
+
170
+ close(): void {
171
+ this.hl7Connection.close();
172
+ this.webSocket.close();
173
+ }
57
174
  }
58
175
 
59
176
  if (typeof require !== 'undefined' && require.main === module) {
60
- new App(new MedplumClient(), { resourceType: 'Bot', id: '00000000-00000000-00000000-00000000' }).start();
177
+ if (process.argv.length < 6) {
178
+ console.log('Usage: node medplum-agent.js <baseUrl> <clientId> <clientSecret> <agentId>');
179
+ process.exit(1);
180
+ }
181
+
182
+ const [_node, _script, baseUrl, clientId, clientSecret, agentId] = process.argv;
183
+ const medplum = new MedplumClient({ baseUrl, clientId });
184
+ medplum
185
+ .startClientLogin(clientId, clientSecret)
186
+ .then(() => new App(medplum, agentId).start())
187
+ .catch(console.error);
61
188
  }