fhirsmith 0.6.0 → 0.7.0

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -0
  3. package/configurations/projector.json +21 -0
  4. package/configurations/readme.md +5 -0
  5. package/library/package-manager.js +0 -2
  6. package/library/version-utilities.js +85 -0
  7. package/package.json +1 -1
  8. package/packages/package-crawler.js +44 -9
  9. package/packages/packages.js +1 -0
  10. package/registry/crawler.js +35 -14
  11. package/registry/registry.js +3 -0
  12. package/server.js +4 -0
  13. package/tx/README.md +4 -4
  14. package/tx/cs/cs-loinc.js +5 -2
  15. package/tx/cs/cs-provider-api.js +25 -1
  16. package/tx/cs/cs-provider-list.js +2 -2
  17. package/tx/library/canonical-resource.js +6 -1
  18. package/tx/library.js +127 -10
  19. package/tx/ocl/README.md +236 -0
  20. package/tx/ocl/cache/cache-paths.cjs +32 -0
  21. package/tx/ocl/cache/cache-paths.js +2 -0
  22. package/tx/ocl/cache/cache-utils.cjs +43 -0
  23. package/tx/ocl/cache/cache-utils.js +2 -0
  24. package/tx/ocl/cm-ocl.cjs +531 -0
  25. package/tx/ocl/cm-ocl.js +1 -105
  26. package/tx/ocl/cs-ocl.cjs +1779 -0
  27. package/tx/ocl/cs-ocl.js +1 -38
  28. package/tx/ocl/fingerprint/fingerprint.cjs +67 -0
  29. package/tx/ocl/fingerprint/fingerprint.js +2 -0
  30. package/tx/ocl/http/client.cjs +31 -0
  31. package/tx/ocl/http/client.js +2 -0
  32. package/tx/ocl/http/pagination.cjs +98 -0
  33. package/tx/ocl/http/pagination.js +2 -0
  34. package/tx/ocl/jobs/background-queue.cjs +200 -0
  35. package/tx/ocl/jobs/background-queue.js +2 -0
  36. package/tx/ocl/mappers/concept-mapper.cjs +66 -0
  37. package/tx/ocl/mappers/concept-mapper.js +2 -0
  38. package/tx/ocl/model/concept-filter-context.cjs +51 -0
  39. package/tx/ocl/model/concept-filter-context.js +2 -0
  40. package/tx/ocl/shared/constants.cjs +15 -0
  41. package/tx/ocl/shared/constants.js +2 -0
  42. package/tx/ocl/shared/patches.cjs +224 -0
  43. package/tx/ocl/shared/patches.js +2 -0
  44. package/tx/ocl/vs-ocl.cjs +1848 -0
  45. package/tx/ocl/vs-ocl.js +1 -104
  46. package/tx/operation-context.js +8 -1
  47. package/tx/params.js +24 -3
  48. package/tx/provider.js +47 -0
  49. package/tx/tx-html.js +1 -1
  50. package/tx/tx.js +8 -0
  51. package/tx/vs/vs-vsac.js +4 -3
  52. package/tx/workers/batch-validate.js +3 -2
  53. package/tx/workers/batch.js +3 -2
  54. package/tx/workers/expand.js +64 -9
  55. package/tx/workers/lookup.js +5 -4
  56. package/tx/workers/read.js +2 -1
  57. package/tx/workers/related.js +3 -2
  58. package/tx/workers/search.js +4 -9
  59. package/tx/workers/subsumes.js +3 -2
  60. package/tx/workers/translate.js +4 -3
  61. package/tx/workers/validate.js +132 -40
  62. package/tx/workers/worker.js +1 -7
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to the Health Intersections Node Server will be documented i
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [v0.7.0] - 2026-03-13
9
+
10
+ ### Added
11
+ - Add support for serving for OCL TX content (h/t Italo Macêdo from the OCL team)
12
+ - Add default configurations (wip)
13
+
14
+ ### Changed
15
+ - Make web-crawlers more robust after tx.fhir.org crash
16
+ - Don't accept NPM packages that have .js code or install scripts
17
+
18
+ ### Fixed
19
+ - Fix many bugs in expansion and validation for value sets that include two different versions of the same code system
20
+ - Fix CodeSystem search on system parameter to reduce user confusion
21
+ - Fix CodeSystem search such that default search is without any specified source
22
+ - Fix headers sent multiple times error
23
+
24
+ ### Tx Conformance Statement
25
+
26
+ FHIRsmith passed all 1452 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1-SNAPSHOT, runner v6.8.2)
27
+
8
28
  ## [v0.6.0] - 2026-03-06
9
29
 
10
30
  ### Added
package/README.md CHANGED
@@ -229,6 +229,8 @@ GitHub Actions will automatically:
229
229
  - Change description
230
230
  ### Fixed
231
231
  - Bug fix description
232
+ ### Tx Conformance Statement
233
+ {copy content from text-cases-summary.txt}
232
234
  ```
233
235
  2. Update `package.json` to have the same release version
234
236
 
@@ -0,0 +1,21 @@
1
+ {
2
+ "hostName" : "NPM Publisher",
3
+ "server": {
4
+ "port": 3001,
5
+ "cors": {
6
+ "origin": true,
7
+ "credentials": true
8
+ }
9
+ },
10
+ "modules": {
11
+ "npmprojector": {
12
+ "enabled": false,
13
+ "fhirVersion": "r4",
14
+ "basePath": "/us-core",
15
+ "npm": "hl7.fhir.us.core#7.0.1",
16
+ "resourceFolders": ["data"],
17
+ "searchParametersFolder": "data",
18
+ "debounceMs": 500
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,5 @@
1
+ This folder contains some basic starter configurations:
2
+
3
+ * Terminology server: see tx-config.json for a vanilla server that doesn't contain any licensed content
4
+ * NPM web server: see projector.json for a basic configuration to make a package available online
5
+
@@ -380,7 +380,6 @@ class PackageManager {
380
380
  return cachedPath;
381
381
  }
382
382
 
383
- console.log("Fetch Package "+packageId+"#"+version);
384
383
  // Not in cache, fetch from servers
385
384
  const packageData = await this.fetchFromServers(packageId, resolvedVersion);
386
385
 
@@ -593,7 +592,6 @@ class PackageManager {
593
592
  * @returns {Promise<string>} Path to extracted package folder
594
593
  */
595
594
  async fetchUrl(url) {
596
- console.log("Fetch Package from URL: " + url);
597
595
  try {
598
596
  const client = new CIBuildClient();
599
597
  const packageData = await client.fetchFromUrlSpecific(url);
@@ -1051,6 +1051,91 @@ class VersionUtilities {
1051
1051
  return url;
1052
1052
  }
1053
1053
  }
1054
+
1055
+
1056
+ static isAnInteger(version) {
1057
+ return /^\d+$/.test(version);
1058
+ }
1059
+
1060
+ static appearsToBeDate(version) {
1061
+ if (!version || typeof version !== 'string') return false;
1062
+ // Strip optional time portion (T...) before checking
1063
+ const datePart = version.split('T')[0];
1064
+ return /^\d{4}-?\d{2}(-?\d{2})?$/.test(datePart);
1065
+
1066
+ }
1067
+
1068
+ static guessVersionAlgorithmFromVersion(version) {
1069
+ if (VersionUtilities.isSemVerWithWildcards(version)) {
1070
+ return 'semver';
1071
+ }
1072
+ if (this.appearsToBeDate(version)) {
1073
+ return 'date';
1074
+ }
1075
+ if (this.isAnInteger(version)) {
1076
+ return 'integer';
1077
+ }
1078
+ return 'alpha';
1079
+ }
1080
+
1081
+ static dateIsMoreRecent(date, date2) {
1082
+ return VersionUtilities.normaliseDateString(date) > VersionUtilities.normaliseDateString(date2);
1083
+ }
1084
+
1085
+ static normaliseDateString(date) {
1086
+ // Strip time portion, then remove dashes so all formats compare uniformly as YYYYMMDD or YYYYMM
1087
+ return date.split('T')[0].replace(/-/g, '');
1088
+ }
1089
+
1090
+
1091
+ /**
1092
+ * guesses the correct format, then compares accordingly
1093
+ */
1094
+ static compareVersionsGeneral(version1, version2) {
1095
+ if (version1 && version2) {
1096
+ if (version1 == version2) {
1097
+ return 0;
1098
+ }
1099
+ const fmt1 = VersionUtilities.guessVersionAlgorithmFromVersion(version1);
1100
+ const fmt2 = VersionUtilities.guessVersionAlgorithmFromVersion(version2);
1101
+ if (fmt1 != fmt2) {
1102
+ return version1.localeCompare(version2);
1103
+ }
1104
+ switch (fmt1) {
1105
+ case 'semver': {
1106
+ let b1 = VersionUtilities.isThisOrLater(version1, version2, VersionPrecision.PATCH);
1107
+ let b2 = VersionUtilities.isThisOrLater(version2, version1, VersionPrecision.PATCH);
1108
+ if (b1 && b2) {
1109
+ return 0;
1110
+ } else if (b2) {
1111
+ return 1;
1112
+ } else {
1113
+ return -1;
1114
+ }
1115
+ }
1116
+ case 'date':
1117
+ if (VersionUtilities.dateIsMoreRecent(version1, version2)) {
1118
+ return 1;
1119
+ } else if (VersionUtilities.dateIsMoreRecent(version2, version1)) {
1120
+ return -1;
1121
+ } else {
1122
+ return 0;
1123
+ }
1124
+ case 'integer':
1125
+ return parseInt(version1, 10) - parseInt(version2, 10);
1126
+ case 'alpha':
1127
+ return version1.localeCompare(version2);
1128
+ default:
1129
+ return version1.localeCompare(version2);
1130
+ }
1131
+ } else if (version1) {
1132
+ return 1;
1133
+ } else if (version2) {
1134
+ return -1;
1135
+ } else {
1136
+ return 0;
1137
+ }
1138
+ }
1054
1139
  }
1055
1140
 
1056
1141
  module.exports = { VersionUtilities, VersionPrecision, SemverParser };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
5
5
  "main": "server.js",
6
6
  "engines": {
@@ -9,11 +9,12 @@ const {XMLParser} = require('fast-xml-parser');
9
9
  const crypto = require('crypto');
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
+ const {debugLog} = require("../tx/operation-context");
12
13
 
13
14
  class PackageCrawler {
14
15
  log;
15
16
  packages = new Set();
16
-
17
+
17
18
  constructor(config, db, stats) {
18
19
  this.config = config;
19
20
  this.db = db;
@@ -21,6 +22,7 @@ class PackageCrawler {
21
22
  this.totalBytes = 0;
22
23
  this.crawlerLog = {};
23
24
  this.errors = '';
25
+ this.abortController = null;
24
26
  this.db.run('PRAGMA journal_mode = WAL');
25
27
  this.db.run('PRAGMA busy_timeout = 5000');
26
28
  }
@@ -28,7 +30,8 @@ class PackageCrawler {
28
30
  async crawl(log) {
29
31
  this.log = log;
30
32
  this.packages.clear();
31
-
33
+ this.abortController = new AbortController();
34
+
32
35
  const startTime = Date.now();
33
36
  this.crawlerLog = {
34
37
  startTime: new Date().toISOString(),
@@ -54,6 +57,7 @@ class PackageCrawler {
54
57
 
55
58
  // Process each feed
56
59
  for (const feedConfig of masterResponse.feeds) {
60
+ if (this.abortController?.signal.aborted) break;
57
61
  if (!feedConfig.url) {
58
62
  this.log.info('Skipping feed with no URL: '+ feedConfig);
59
63
  continue;
@@ -71,6 +75,7 @@ class PackageCrawler {
71
75
  }
72
76
  // process simplifier last
73
77
  for (const feedConfig of masterResponse.feeds) {
78
+ if (this.abortController?.signal.aborted) break;
74
79
  if (!feedConfig.url) {
75
80
  this.log.info('Skipping feed with no URL: '+ feedConfig);
76
81
  continue;
@@ -123,6 +128,7 @@ class PackageCrawler {
123
128
  } else {
124
129
  const response = await axios.get(url, {
125
130
  timeout: 30000,
131
+ signal: this.abortController?.signal,
126
132
  headers: {
127
133
  'User-Agent': 'FHIR Package Crawler/1.0'
128
134
  }
@@ -130,7 +136,7 @@ class PackageCrawler {
130
136
  return response.data;
131
137
  }
132
138
  } catch (error) {
133
- console.log(error);
139
+ debugLog(error);
134
140
  if (error.response && error.response.status === 429) {
135
141
  throw new Error(`RATE_LIMITED: Server returned 429 Too Many Requests for ${url}`);
136
142
  }
@@ -151,6 +157,7 @@ class PackageCrawler {
151
157
  } else {
152
158
  const response = await axios.get(url, {
153
159
  timeout: 30000,
160
+ signal: this.abortController?.signal,
154
161
  headers: {
155
162
  'User-Agent': 'FHIR Package Crawler/1.0'
156
163
  }
@@ -165,6 +172,7 @@ class PackageCrawler {
165
172
  return parser.parse(response.data);
166
173
  }
167
174
  } catch (error) {
175
+ debugLog(error);
168
176
  if (error.response && error.response.status === 429) {
169
177
  throw new Error(`RATE_LIMITED: Server returned 429 Too Many Requests for ${url}`);
170
178
  }
@@ -182,6 +190,7 @@ class PackageCrawler {
182
190
  const response = await axios.get(url, {
183
191
  timeout: 60000,
184
192
  responseType: 'arraybuffer',
193
+ signal: this.abortController?.signal,
185
194
  headers: {
186
195
  'User-Agent': 'FHIR Package Crawler/1.0'
187
196
  }
@@ -191,6 +200,7 @@ class PackageCrawler {
191
200
  return Buffer.from(response.data);
192
201
  }
193
202
  } catch (error) {
203
+ debugLog(error);
194
204
  if (error.response && error.response.status === 429) {
195
205
  throw new Error(`RATE_LIMITED: Server returned 429 Too Many Requests for ${url}`);
196
206
  }
@@ -222,6 +232,7 @@ class PackageCrawler {
222
232
  this.log.info(`Found ${items.length} items in feed`);
223
233
 
224
234
  for (let i = 0; i < items.length; i++) {
235
+ if (this.abortController?.signal.aborted) break;
225
236
  try {
226
237
  await this.updateItem(url, items[i], i, packageRestrictions, feedLog);
227
238
  } catch (itemError) {
@@ -244,7 +255,7 @@ class PackageCrawler {
244
255
  }
245
256
 
246
257
  } catch (error) {
247
- console.log(error);
258
+ debugLog(error);
248
259
  // Check if this is a 429 error on feed fetch
249
260
  if (error.message.includes('RATE_LIMITED')) {
250
261
  this.log.info(`Rate limited while fetching feed ${url}, skipping this feed`);
@@ -302,7 +313,7 @@ class PackageCrawler {
302
313
  }
303
314
 
304
315
  // Check package restrictions
305
- if (!this.isPackageAllowed(id, source, packageRestrictions)) {
316
+ if (!this.isPackageAllowed(id, source, packageRestrictions).allowed) {
306
317
  if (!source.includes('simplifier.net')) {
307
318
  const error = `The package ${id} is not allowed to come from ${source}`;
308
319
  this.log.info(error);
@@ -329,11 +340,12 @@ class PackageCrawler {
329
340
 
330
341
  // Parse publication date
331
342
  let pubDate;
343
+ let pd;
332
344
  try {
333
345
  let pd = item.pubDate;
334
346
  pubDate = this.parsePubDate(pd);
335
347
  } catch (error) {
336
- itemLog.error = `Invalid date format '{pd}': ${error.message}`;
348
+ itemLog.error = `Invalid date format '${pd}': ${error.message}`;
337
349
  itemLog.status = 'error';
338
350
  return;
339
351
  }
@@ -355,7 +367,7 @@ class PackageCrawler {
355
367
  itemLog.status = 'Fetched';
356
368
 
357
369
  } catch (error) {
358
- this.log.error(`Exception processing item ${itemLog.guid || index}:`+ error.message);
370
+ this.log.error(`Exception processing item ${itemLog.guid || index} from ${source}: `+ error.message);
359
371
  itemLog.status = 'Exception';
360
372
  itemLog.error = error.message;
361
373
  if (error.message.includes('RATE_LIMITED')) {
@@ -383,7 +395,7 @@ class PackageCrawler {
383
395
 
384
396
  if (this.matchesPattern(fixedPackageId, fixedMask)) {
385
397
  // This package matches a restriction - check if source is allowed
386
- const allowedFeeds = restriction.feeds.map(feed => feed);
398
+ const allowedFeeds = restriction.feeds.map(feed => fixUrl(feed));
387
399
  const feedList = allowedFeeds.join(', ');
388
400
 
389
401
  for (const allowedFeed of restriction.feeds) {
@@ -485,6 +497,14 @@ class PackageCrawler {
485
497
  throw new Error(`NPM Canonical "${canonical}" is not valid from ${source}`);
486
498
  }
487
499
 
500
+ const isTemplate = npmPackage.kind === 2; // fhir.template
501
+ if (npmPackage.hasInstallScripts) {
502
+ throw new Error(`Package ${idver} rejected: contains install scripts (preinstall/install/postinstall)`);
503
+ }
504
+ if (npmPackage.hasJavaScript && !isTemplate) {
505
+ throw new Error(`Package ${idver} rejected: contains JavaScript files but is not a template package`);
506
+ }
507
+
488
508
  // Extract URLs from package
489
509
  const urls = this.processPackageUrls(npmPackage);
490
510
 
@@ -492,7 +512,7 @@ class PackageCrawler {
492
512
  await this.commit(packageBuffer, npmPackage, date, guid, id, version, canonical, urls);
493
513
 
494
514
  } catch (error) {
495
- console.log(error);
515
+ debugLog(error);
496
516
  this.log.error(`Error storing package ${guid}:`+ error.message);
497
517
  throw error;
498
518
  }
@@ -555,6 +575,14 @@ class PackageCrawler {
555
575
  }
556
576
 
557
577
  const packageJson = JSON.parse(files['package.json']);
578
+ const hasInstallScripts = !!(
579
+ packageJson.scripts && (
580
+ packageJson.scripts.preinstall ||
581
+ packageJson.scripts.install ||
582
+ packageJson.scripts.postinstall
583
+ )
584
+ );
585
+ const hasJavaScript = Object.keys(files).some(f => f.endsWith('.js') || f.endsWith('.mjs') || f.endsWith('.cjs'));
558
586
 
559
587
  // Extract basic NPM fields
560
588
  const id = packageJson.name || '';
@@ -664,6 +692,8 @@ class PackageCrawler {
664
692
  url: homepage,
665
693
  dependencies,
666
694
  kind,
695
+ hasInstallScripts,
696
+ hasJavaScript,
667
697
  notForPublication,
668
698
  files
669
699
  };
@@ -903,6 +933,11 @@ class PackageCrawler {
903
933
  return id;
904
934
  }
905
935
  }
936
+ shutdown() {
937
+ if (this.abortController) {
938
+ this.abortController.abort();
939
+ }
940
+ }
906
941
  }
907
942
 
908
943
  module.exports = PackageCrawler;
@@ -803,6 +803,7 @@ class PackagesModule {
803
803
 
804
804
  stopCrawlerJob() {
805
805
  if (this.crawlerJob) {
806
+ this.crawler.shutdown();
806
807
  this.crawlerJob.stop();
807
808
  this.crawlerJob = null;
808
809
  pckLog.info('Package crawler job stopped');
@@ -9,6 +9,7 @@ const {
9
9
  ServerVersionInformation,
10
10
  } = require('./model');
11
11
  const {Extensions} = require("../tx/library/extensions");
12
+ const {debugLog} = require("../tx/operation-context");
12
13
 
13
14
  const MASTER_URL = 'https://fhir.github.io/ig-registry/tx-servers.json';
14
15
 
@@ -31,6 +32,7 @@ class RegistryCrawler {
31
32
  this.errors = [];
32
33
  this.totalBytes = 0;
33
34
  this.log = console;
35
+ this.abortController = null;
34
36
  }
35
37
 
36
38
  useLog(logv) {
@@ -48,6 +50,7 @@ class RegistryCrawler {
48
50
  this.addLogEntry('warn', 'Crawl already in progress, skipping...');
49
51
  return this.currentData;
50
52
  }
53
+ this.abortController = new AbortController();
51
54
 
52
55
  this.isCrawling = true;
53
56
  const startTime = new Date();
@@ -75,6 +78,7 @@ class RegistryCrawler {
75
78
  // Process each registry
76
79
  const registries = masterJson.registries || [];
77
80
  for (const registryConfig of registries) {
81
+ if (this.abortController?.signal.aborted) break;
78
82
  const registry = await this.processRegistry(registryConfig);
79
83
  if (registry) {
80
84
  newData.registries.push(registry);
@@ -86,7 +90,7 @@ class RegistryCrawler {
86
90
  // Update the current data
87
91
  this.currentData = newData;
88
92
  } catch (error) {
89
- console.log(error);
93
+ debugLog(error);
90
94
  this.addLogEntry('error', 'Exception Scanning:', error);
91
95
  this.currentData.outcome = `Error: ${error.message}`;
92
96
  this.errors.push({
@@ -118,7 +122,7 @@ class RegistryCrawler {
118
122
  }
119
123
 
120
124
  if (!registry.address) {
121
- this.addLogEntry('error', `No url provided for ${registry.name, registry.name}`, '');
125
+ this.addLogEntry('error', `No url provided for ${registry.name}`, '');
122
126
  return registry;
123
127
  }
124
128
 
@@ -134,6 +138,7 @@ class RegistryCrawler {
134
138
  // Process each server in the registry
135
139
  const servers = registryJson.servers || [];
136
140
  for (const serverConfig of servers) {
141
+ if (this.abortController?.signal.aborted) break;
137
142
  const server = await this.processServer(serverConfig, registry.address);
138
143
  if (server) {
139
144
  registry.servers.push(server);
@@ -141,7 +146,7 @@ class RegistryCrawler {
141
146
  }
142
147
 
143
148
  } catch (error) {
144
- console.log(error);
149
+ debugLog(error);
145
150
  registry.error = error.message;
146
151
  this.addLogEntry('error', `Exception processing registry ${registry.name}: ${error.message}`, registry.address);
147
152
  }
@@ -177,6 +182,7 @@ class RegistryCrawler {
177
182
  // Process each FHIR version
178
183
  const fhirVersions = serverConfig.fhirVersions || [];
179
184
  for (const versionConfig of fhirVersions) {
185
+ if (this.abortController?.signal.aborted) break;
180
186
  const version = await this.processServerVersion(versionConfig, server, serverConfig.exclusions);
181
187
  if (version) {
182
188
  server.versions.push(version);
@@ -231,7 +237,7 @@ class RegistryCrawler {
231
237
  this.addLogEntry('info', ` Server ${version.address}: ${version.lastTat} for ${version.codeSystems.length} CodeSystems and ${version.valueSets.length} ValueSets`);
232
238
 
233
239
  } catch (error) {
234
- console.log(error);
240
+ debugLog(error);
235
241
  const elapsed = Date.now() - startTime;
236
242
  this.addLogEntry('error', `Server ${version.address}: Error after ${elapsed}ms: ${error.message}`);
237
243
  version.error = error.message;
@@ -276,10 +282,11 @@ class RegistryCrawler {
276
282
  });
277
283
  }
278
284
  } catch (error) {
279
- console.log(error);
280
- this.addLogEntry('error', `Could not fetch terminology capabilities: ${error.message}`);
285
+ debugLog(error);
286
+ this.addLogEntry('error', `Could not fetch terminology capabilities from ${version.address}: ${error.message}`);
281
287
  }
282
-
288
+
289
+ if (this.abortController?.signal.aborted) return;
283
290
  // Search for value sets
284
291
  await this.fetchValueSets(version, server, exclusions);
285
292
  }
@@ -324,8 +331,8 @@ class RegistryCrawler {
324
331
  });
325
332
  }
326
333
  } catch (error) {
327
- console.log(error);
328
- this.addLogEntry('error', `Could not fetch terminology capabilities: ${error.message}`);
334
+ debugLog(error);
335
+ this.addLogEntry('error', `Could not fetch terminology capabilities from ${version.address}: ${error.message}`);
329
336
  }
330
337
 
331
338
  // Search for value sets
@@ -342,14 +349,20 @@ class RegistryCrawler {
342
349
  */
343
350
  async fetchValueSets(version, server, exclusions) {
344
351
  // Initial search URL
352
+ let count = 0;
345
353
  let searchUrl = `${version.address}/ValueSet?_elements=url,version`+(version.address.includes("fhir.org") ? "&_count=200" : "");
346
354
  try {
347
355
  // Set of URLs to avoid duplicates
348
356
  const valueSetUrls = new Set();
349
357
 
350
-
351
358
  // Continue fetching while we have a URL
352
359
  while (searchUrl) {
360
+ count++;
361
+ if (count == 1000) {
362
+ throw new Error(`Fetch ValueSet loop exceeded 1000 iterations - a logic problem on the server? (${version.address})`);
363
+ }
364
+
365
+ if (this.abortController?.signal.aborted) break;
353
366
  this.log.debug(`Fetching value sets from ${searchUrl}`);
354
367
  const bundle = await this.fetchJson(searchUrl, server.code);
355
368
 
@@ -382,7 +395,7 @@ class RegistryCrawler {
382
395
  version.valueSets = Array.from(valueSetUrls).sort();
383
396
 
384
397
  } catch (error) {
385
- console.log(error);
398
+ debugLog(error);
386
399
  this.addLogEntry('error', `Could not fetch value sets: ${error.message} from ${searchUrl}`);
387
400
  }
388
401
  }
@@ -441,6 +454,7 @@ class RegistryCrawler {
441
454
  const response = await axios.get(fetchUrl, {
442
455
  timeout: this.config.timeout,
443
456
  headers: headers,
457
+ signal: this.abortController?.signal,
444
458
  validateStatus: (status) => status < 500 // Don't throw on 4xx
445
459
  });
446
460
 
@@ -459,7 +473,7 @@ class RegistryCrawler {
459
473
  return response.data;
460
474
 
461
475
  } catch (error) {
462
- console.log(error);
476
+ debugLog(error);
463
477
  if (error.response) {
464
478
  throw new Error(`HTTP ${error.response.status}: ${error.response.statusText}`);
465
479
  } else if (error.request) {
@@ -603,14 +617,14 @@ class RegistryCrawler {
603
617
  * @param {string} level - Filter by log level
604
618
  * @returns {Array} Array of log entries
605
619
  */
606
- getLogs(limit = 100)
620
+ getLogs(limit = 100, level = null)
607
621
  {
608
622
  if (!this.logs) {
609
623
  return [];
610
624
  }
611
625
 
612
626
  // Filter by level if specified
613
- let filteredLogs = this.logs;
627
+ let filteredLogs = level ? this.logs.filter(entry => entry.level === level) : this.logs;
614
628
 
615
629
  // Get the latest entries up to the limit
616
630
  return filteredLogs.slice(-limit);
@@ -648,6 +662,13 @@ class RegistryCrawler {
648
662
  }
649
663
  return false;
650
664
  }
665
+
666
+ shutdown() {
667
+ if (this.abortController) {
668
+ this.abortController.abort();
669
+ }
670
+ }
671
+
651
672
  }
652
673
 
653
674
  module.exports = RegistryCrawler;
@@ -926,6 +926,9 @@ class RegistryModule {
926
926
  this.crawlInterval = null;
927
927
  }
928
928
 
929
+ if (this.crawler) {
930
+ this.crawler.shutdown();
931
+ }
929
932
  // Save current data
930
933
  if (this.crawler && this.currentData) {
931
934
  await this.saveData();
package/server.js CHANGED
@@ -436,9 +436,11 @@ app.get('/', async (req, res) => {
436
436
  const html = htmlServer.renderPage('root', escape(config.hostName) || 'FHIRsmith Server', content, stats);
437
437
  res.setHeader('Content-Type', 'text/html');
438
438
  res.send(html);
439
+ return;
439
440
  } catch (error) {
440
441
  serverLog.error('Error rendering root page:', error);
441
442
  htmlServer.sendErrorResponse(res, 'root', error);
443
+ return;
442
444
  }
443
445
  }
444
446
  return serveFhirsmithHome(req, res);
@@ -623,9 +625,11 @@ async function serveFhirsmithHome(req, res) {
623
625
  const html = htmlServer.renderPage('root', escape(config.hostName) || 'FHIRsmith Server', content, stats);
624
626
  res.setHeader('Content-Type', 'text/html');
625
627
  res.send(html);
628
+ return;
626
629
  } catch (error) {
627
630
  serverLog.error('Error rendering root page:', error);
628
631
  htmlServer.sendErrorResponse(res, 'root', error);
632
+ return;
629
633
  }
630
634
  } else {
631
635
  // Return JSON response for API clients
package/tx/README.md CHANGED
@@ -33,22 +33,22 @@ Add the `tx` section to your `config.json`:
33
33
  "endpoints": [
34
34
  {
35
35
  "path": "/tx/r5",
36
- "fhirVersion": 5,
36
+ "fhirVersion": "5.0",
37
37
  "context": null
38
38
  },
39
39
  {
40
40
  "path": "/tx/r4",
41
- "fhirVersion": 4,
41
+ "fhirVersion": "4.0",
42
42
  "context": null
43
43
  },
44
44
  {
45
45
  "path": "/tx/r3",
46
- "fhirVersion": 3,
46
+ "fhirVersion": "3.0",
47
47
  "context": null
48
48
  },
49
49
  {
50
50
  "path": "/tx/r4/demo",
51
- "fhirVersion": 4,
51
+ "fhirVersion": "4.0",
52
52
  "context": "demo"
53
53
  }
54
54
  ]
package/tx/cs/cs-loinc.js CHANGED
@@ -203,9 +203,12 @@ class LoincServices extends BaseCSServices {
203
203
  // Use language-aware display logic
204
204
  if (this.opContext.langs && !this.opContext.langs.isEnglishOrNothing()) {
205
205
  const displays = await this.#getDisplaysForContext(ctxt, this.opContext.langs);
206
+ const requestedLanguages = Array.isArray(this.opContext.langs.languages)
207
+ ? this.opContext.langs.languages
208
+ : (Array.isArray(this.opContext.langs.langs) ? this.opContext.langs.langs : []);
206
209
 
207
210
  // Try to find exact language match
208
- for (const lang of this.opContext.langs.langs) {
211
+ for (const lang of requestedLanguages) {
209
212
  for (const display of displays) {
210
213
  if (lang.matches(display.language, true)) {
211
214
  return display.value;
@@ -214,7 +217,7 @@ class LoincServices extends BaseCSServices {
214
217
  }
215
218
 
216
219
  // Try partial language match
217
- for (const lang of this.opContext.langs.langs) {
220
+ for (const lang of requestedLanguages) {
218
221
  for (const display of displays) {
219
222
  if (lang.matches(display.language, false)) {
220
223
  return display.value;