fhirsmith 0.7.5 → 0.8.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 (46) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +8 -0
  3. package/library/html.js +4 -0
  4. package/library/languages.js +10 -0
  5. package/package.json +1 -1
  6. package/packages/package-crawler.js +106 -51
  7. package/packages/packages.js +14 -0
  8. package/publisher/publisher.js +118 -28
  9. package/registry/registry.js +99 -91
  10. package/root-bare-template.html +92 -0
  11. package/security.md +32 -0
  12. package/server.js +99 -22
  13. package/stats.js +43 -10
  14. package/tx/README.md +6 -6
  15. package/tx/cs/cs-api.js +3 -0
  16. package/tx/cs/cs-api.md +285 -0
  17. package/tx/cs/cs-loinc.js +14 -2
  18. package/tx/cs/cs-rxnorm.js +14 -10
  19. package/tx/cs/cs-snomed.js +166 -5
  20. package/tx/html/dash-metrics.liquid +147 -0
  21. package/tx/importers/import-rxnorm.module.js +4 -30
  22. package/tx/importers/readme.md +3 -1
  23. package/tx/library/canonical-resource.js +8 -0
  24. package/tx/library/conceptmap.js +3 -1
  25. package/tx/library/designations.js +4 -8
  26. package/tx/library/renderer.js +9 -9
  27. package/tx/library.js +10 -4
  28. package/tx/ocl/cm-ocl.cjs +185 -65
  29. package/tx/ocl/cs-ocl.cjs +69 -50
  30. package/tx/ocl/jobs/background-queue.cjs +0 -8
  31. package/tx/ocl/mappers/concept-mapper.cjs +13 -3
  32. package/tx/ocl/shared/patches.cjs +1 -0
  33. package/tx/ocl/vs-ocl.cjs +137 -157
  34. package/tx/operation-context.js +3 -3
  35. package/tx/provider.js +4 -3
  36. package/tx/sct/structures.js +5 -0
  37. package/tx/tx-html.js +36 -9
  38. package/tx/tx.fhir.org.yml +1 -1
  39. package/tx/tx.js +34 -11
  40. package/tx/vs/vs-database.js +127 -6
  41. package/tx/vs/vs-vsac.js +98 -3
  42. package/tx/workers/search.js +2 -1
  43. package/tx/workers/translate.js +39 -14
  44. package/tx/workers/validate.js +3 -3
  45. package/utilities/dashboard.html +274 -0
  46. package/xig/xig.js +171 -9
package/server.js CHANGED
@@ -269,7 +269,6 @@ async function loadTemplates() {
269
269
 
270
270
  async function buildRootPageContent() {
271
271
  stats.requestCount++;
272
- let mc = 0;
273
272
  let content = '<div class="row mb-4">';
274
273
  content += '<div class="col-12">';
275
274
 
@@ -278,35 +277,30 @@ async function buildRootPageContent() {
278
277
 
279
278
  // Check which modules are enabled and add them to the list
280
279
  if (config.modules.packages.enabled) {
281
- mc++;
282
280
  content += '<li class="list-group-item">';
283
281
  content += '<a href="/packages" class="text-decoration-none">Package Server</a>: Browse and download FHIR Implementation Guide packages';
284
282
  content += '</li>';
285
283
  }
286
284
 
287
285
  if (config.modules.xig.enabled) {
288
- mc++;
289
286
  content += '<li class="list-group-item">';
290
287
  content += '<a href="/xig" class="text-decoration-none">FHIR IG Statistics</a>: Statistics and analysis of FHIR Implementation Guides';
291
288
  content += '</li>';
292
289
  }
293
290
 
294
291
  if (config.modules.shl.enabled) {
295
- mc++;
296
292
  content += '<li class="list-group-item">';
297
293
  content += '<a href="/shl" class="text-decoration-none">SHL Server</a>: SMART Health Links management and validation';
298
294
  content += '</li>';
299
295
  }
300
296
 
301
297
  if (config.modules.vcl.enabled) {
302
- mc++;
303
298
  content += '<li class="list-group-item">';
304
299
  content += '<a href="/VCL" class="text-decoration-none">VCL Server</a>: ValueSet Compose Language expression parsing';
305
300
  content += '</li>';
306
301
  }
307
302
 
308
303
  if (config.modules.registry && config.modules.registry.enabled) {
309
- mc++;
310
304
  content += '<li class="list-group-item">';
311
305
  content += '<a href="/tx-reg" class="text-decoration-none">Terminology Server Registry</a>: ';
312
306
  content += 'Discover and query FHIR terminology servers for code system and value set support';
@@ -314,7 +308,6 @@ async function buildRootPageContent() {
314
308
  }
315
309
 
316
310
  if (config.modules.publisher && config.modules.publisher.enabled) {
317
- mc++;
318
311
  content += '<li class="list-group-item">';
319
312
  content += '<a href="/publisher" class="text-decoration-none">FHIR Publisher</a>: ';
320
313
  content += 'Manage FHIR Implementation Guide publication tasks and approvals';
@@ -322,7 +315,6 @@ async function buildRootPageContent() {
322
315
  }
323
316
 
324
317
  if (config.modules.token && config.modules.token.enabled) {
325
- mc++;
326
318
  content += '<li class="list-group-item">';
327
319
  content += '<a href="/token" class="text-decoration-none">Token Server</a>: ';
328
320
  content += 'OAuth authentication and API key management for FHIR services';
@@ -330,7 +322,6 @@ async function buildRootPageContent() {
330
322
  }
331
323
 
332
324
  if (config.modules.npmprojector && config.modules.npmprojector.enabled) {
333
- mc++;
334
325
  content += '<li class="list-group-item">';
335
326
  content += '<a href="/npmprojector" class="text-decoration-none">NpmProjector</a>: ';
336
327
  content += 'Hot-reloading FHIR server with FHIRPath-based search indexes';
@@ -338,7 +329,6 @@ async function buildRootPageContent() {
338
329
  }
339
330
 
340
331
  if (config.modules?.['ext-tracker']?.enabled) {
341
- mc++;
342
332
  content += '<li class="list-group-item">';
343
333
  content += '<a href="/ext-tracker" class="text-decoration-none">Extension Tracker</a>: ';
344
334
  content += 'View of Extension Usage';
@@ -353,7 +343,6 @@ async function buildRootPageContent() {
353
343
  content += '<ul class="mt-2 mb-0">';
354
344
  for (const fc of folders) {
355
345
  if (fc.enabled === false) continue;
356
- mc++;
357
346
  content += '<li>';
358
347
  content += `<a href="${fc.url}" class="text-decoration-none">${fc.name}</a>: `;
359
348
  content += 'File folder with write control';
@@ -370,7 +359,6 @@ async function buildRootPageContent() {
370
359
  if (config.modules.tx.endpoints && config.modules.tx.endpoints.length > 0) {
371
360
  content += '<ul class="mt-2 mb-0">';
372
361
  for (const endpoint of config.modules.tx.endpoints) {
373
- mc++;
374
362
  content += `<li><a href="${endpoint.path}" class="text-decoration-none">${endpoint.path}</a> (FHIR v${endpoint.fhirVersion}${endpoint.context ? ', context: ' + endpoint.context : ''})</li>`;
375
363
  }
376
364
  content += '</ul>';
@@ -382,7 +370,6 @@ async function buildRootPageContent() {
382
370
 
383
371
  content += '<hr/>';
384
372
 
385
-
386
373
  // Calculate uptime
387
374
  const uptimeMs = Date.now() - stats.startTime;
388
375
  const uptimeSeconds = Math.floor(uptimeMs / 1000);
@@ -399,7 +386,7 @@ async function buildRootPageContent() {
399
386
  // Memory usage
400
387
  const memUsage = process.memoryUsage();
401
388
  const heapUsedMB = (memUsage.heapUsed / 1024 / 1024).toFixed(2);
402
- const heapTotalMB = (memUsage.heapTotal / 1024 / 1024).toFixed(2);
389
+ const heapAvailableMB = ((memUsage.heapTotal - memUsage.heapUsed) / 1024 / 1024).toFixed(2);
403
390
  const rssMB = (memUsage.rss / 1024 / 1024).toFixed(2);
404
391
  const freeMemMB = (os.freemem() / 1024 / 1024).toFixed(0);
405
392
  const totalMemMB = (os.totalmem() / 1024 / 1024).toFixed(0);
@@ -407,20 +394,18 @@ async function buildRootPageContent() {
407
394
  content += '<table class="grid">';
408
395
  content += '<tr>';
409
396
  content += `<td><strong>Uptime:</strong> ${escape(uptimeStr)}</td>`;
410
- content += `<td><strong>Request Count:</strong> ${stats.requestCount}</td>`;
397
+ content += `<td><strong>Request Count:</strong> ${stats.requestCount} (static: ${stats.staticRequestCount})</td>`;
411
398
  content += `<td><strong>Free Memory:</strong> ${freeMemMB} MB of ${totalMemMB} MB</td>`;
412
399
  content += '</tr>';
413
400
  content += '<tr>';
414
401
  content += `<td><strong>Heap Used:</strong> ${heapUsedMB} MB</td>`;
415
- content += `<td><strong>Heap Total:</strong> ${heapTotalMB} MB</td>`;
402
+ content += `<td><strong>Heap Available:</strong> ${heapAvailableMB} MB</td>`;
416
403
  content += `<td><strong>Process Memory:</strong> ${rssMB} MB</td>`;
417
404
  content += '</tr>';
418
405
  content += getLogStats();
419
406
  content += '</table>';
420
407
 
421
-
422
408
  // ===== Metrics Graphs =====
423
-
424
409
  const liquid = new Liquid({
425
410
  root: path.join(__dirname, 'tx', 'html'),
426
411
  extname: '.liquid'
@@ -468,6 +453,10 @@ app.get('/', async (req, res) => {
468
453
  const templatePath = path.join(__dirname, 'root-template.html');
469
454
  htmlServer.loadTemplate('root', templatePath);
470
455
  }
456
+ if (!htmlServer.hasTemplate('root-bare')) {
457
+ const templatePath = path.join(__dirname, 'root-bare-template.html');
458
+ htmlServer.loadTemplate('root-bare', templatePath);
459
+ }
471
460
 
472
461
  const content = await buildRootPageContent();
473
462
 
@@ -503,6 +492,15 @@ app.get('/', async (req, res) => {
503
492
  app.get('/fhirsmith', (req, res) => serveFhirsmithHome(req, res));
504
493
 
505
494
  // Serve static files
495
+ // Count static file hits separately from API/page requests
496
+ app.use((req, res, next) => {
497
+ res.on('finish', () => {
498
+ if (res.statusCode >= 200 && res.statusCode < 300) {
499
+ stats.staticRequestCount++;
500
+ }
501
+ });
502
+ next();
503
+ });
506
504
  if (config.server?.webBase) {
507
505
  const overrideDir = path.resolve(config.server.webBase);
508
506
  app.use((req, res, next) => {
@@ -518,6 +516,85 @@ if (config.server?.webBase) {
518
516
  }
519
517
  app.use(express.static(path.join(__dirname, 'static')));
520
518
 
519
+ // Dashboard endpoint - server name, stats, graphs, and background tasks (no modules list)
520
+ app.get('/dashboard', async (req, res) => {
521
+ stats.requestCount++;
522
+ try {
523
+ if (!htmlServer.hasTemplate('root-bare')) {
524
+ const templatePath = path.join(__dirname, 'root-bare-template.html');
525
+ htmlServer.loadTemplate('root-bare', templatePath);
526
+ }
527
+
528
+ const startTime = Date.now();
529
+ // Calculate uptime
530
+ const uptimeMs = Date.now() - stats.startTime;
531
+ const uptimeSeconds = Math.floor(uptimeMs / 1000);
532
+ const uptimeDays = Math.floor(uptimeSeconds / 86400);
533
+ const uptimeHours = Math.floor((uptimeSeconds % 86400) / 3600);
534
+ const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
535
+ const uptimeSecs = uptimeSeconds % 60;
536
+ let uptimeStr = '';
537
+ if (uptimeDays > 0) uptimeStr += `${uptimeDays}d `;
538
+ if (uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeHours}h `;
539
+ if (uptimeMinutes > 0 || uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeMinutes}m `;
540
+ uptimeStr += `${uptimeSecs}s`;
541
+
542
+ // Memory usage
543
+ const memUsage = process.memoryUsage();
544
+ const heapUsedPCT = (memUsage.heapUsed * 100) / memUsage.heapTotal;
545
+ const freeMemMB = (os.freemem() / 1024 / 1024).toFixed(0);
546
+ const totalMemMB = (os.totalmem() / 1024 / 1024).toFixed(0);
547
+ const usedMemPCT = 100 - ((freeMemMB * 100) / totalMemMB);
548
+ const fstats = fs.statfsSync(folders.logsDir());
549
+ const diskPCT = (fstats.bavail * 100) / fstats.blocks;
550
+
551
+ let content = '';
552
+ content += '<table border="1">';
553
+ content += '<tr>';
554
+ content += `<td><strong>Uptime:</strong> ${escape(uptimeStr)}</td>`;
555
+ content += `<td><strong>Request Count:</strong> ${stats.requestCount} (static: ${stats.staticRequestCount})</td>`;
556
+ content += `<td style="background-color:${pctColor(usedMemPCT)}"><strong>Memory:</strong> ${usedMemPCT.toFixed(0)}%</td>`;
557
+ content += `<td style="background-color:${pctColor(heapUsedPCT)}"><strong>Heap:</strong> ${heapUsedPCT.toFixed(0)}%</td>`;
558
+ content += `<td style="background-color:${pctColor(diskPCT)}"><strong>Disk:</strong> ${diskPCT.toFixed(0)}%</td>`;
559
+ content += '</tr>';
560
+ content += '</table>';
561
+
562
+ // ===== Metrics Graphs =====
563
+ const liquid = new Liquid({
564
+ root: path.join(__dirname, 'tx', 'html'),
565
+ extname: '.liquid'
566
+ });
567
+ content += await liquid.renderFile('dash-metrics', {
568
+ historyJson: JSON.stringify(stats.history),
569
+ startTime: stats.startTime
570
+ });
571
+ content += stats.taskDetails();
572
+ content += `<p>Data: ${folders.dataDir()}</p>`;
573
+
574
+ content = '<div class="row mb-4"><div class="col-12">' + content + '</div></div>';
575
+
576
+ const pageStats = {
577
+ version: packageJson.version,
578
+ enabledModules: Object.keys(config.modules).filter(m => config.modules[m].enabled).length,
579
+ processingTime: Date.now() - startTime
580
+ };
581
+
582
+ const title = (config.hostName ? escape(config.hostName) : 'FHIRsmith Server')+' v'+packageJson.version;
583
+ const html = htmlServer.renderPage('root-bare', title, content, pageStats);
584
+ res.setHeader('Content-Type', 'text/html');
585
+ res.send(html);
586
+ } catch (error) {
587
+ serverLog.error('Error rendering dashboard:', error);
588
+ htmlServer.sendErrorResponse(res, 'root', error);
589
+ }
590
+ });
591
+
592
+ function pctColor(pct) {
593
+ const r = Math.round(pct * 2.55);
594
+ const g = Math.round((100 - pct) * 2.55);
595
+ return `rgb(${r}, ${g}, 100)`; // the 100 keeps it pastel/light
596
+ }
597
+
521
598
  // Health check endpoint
522
599
  app.get('/health', async (req, res) => {
523
600
  const healthStatus = {
@@ -564,10 +641,10 @@ function getLogStats() {
564
641
  let diskInfo = '';
565
642
  try {
566
643
  // statfs available in Node 18.15+
567
- const stats = fs.statfsSync(logDir);
568
- const blockSize = stats.bsize;
569
- const freeSpace = stats.bavail * blockSize;
570
- const totalSpace = stats.blocks * blockSize;
644
+ const fstats = fs.statfsSync(logDir);
645
+ const blockSize = fstats.bsize;
646
+ const freeSpace = fstats.bavail * blockSize;
647
+ const totalSpace = fstats.blocks * blockSize;
571
648
  const freeGB = (freeSpace / 1024 / 1024 / 1024).toFixed(2);
572
649
  const totalGB = (totalSpace / 1024 / 1024 / 1024).toFixed(2);
573
650
  diskInfo = `<td><strong>Disk Space:</strong> ${freeGB} GB of ${totalGB} GB</td>`;
package/stats.js CHANGED
@@ -5,6 +5,7 @@ const escape = require('escape-html');
5
5
  class ServerStats {
6
6
  started = false;
7
7
  requestCount = 0;
8
+ staticRequestCount = 0;
8
9
  requestTime = 0;
9
10
  // Collect metrics every 10 minutes
10
11
  intervalMs = 10 * 60 * 1000;
@@ -27,7 +28,8 @@ class ServerStats {
27
28
  const now = Date.now();
28
29
 
29
30
  const currentMem = process.memoryUsage().heapUsed;
30
- const requestsDelta = this.requestCount - this.requestCountSnapshot;
31
+ const combinedCount = this.requestCount + this.staticRequestCount;
32
+ const requestsDelta = combinedCount - this.requestCountSnapshot;
31
33
  const requestsTat = requestsDelta > 0 ? this.requestTime / requestsDelta : 0;
32
34
  const minutesSinceStart = this.history.length > 1
33
35
  ? this.intervalMs / 60000
@@ -38,7 +40,7 @@ class ServerStats {
38
40
  const idleDelta = currentCpu.idle - this.lastUsage.idle;
39
41
  const totalDelta = currentCpu.total - this.lastUsage.total;
40
42
  const percent = totalDelta > 0 ? 100 * (1 - idleDelta / totalDelta) : 0;
41
-
43
+
42
44
  const loopDelay = this.eventLoopMonitor.mean / 1e6;
43
45
  let cacheCount = 0;
44
46
  for (let m of this.cachingModules) {
@@ -48,11 +50,11 @@ class ServerStats {
48
50
  this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, cpu: percent, block: loopDelay, cache : cacheCount});
49
51
 
50
52
  this.eventLoopMonitor.reset();
51
- this.requestCountSnapshot = this.requestCount;
53
+ this.requestCountSnapshot = combinedCount;
52
54
  this.requestTime = 0;
53
55
  this.lastTime = now;
54
56
  this.lastUsage = currentCpu;
55
-
57
+
56
58
  // Prune old data (keep 24 hours)
57
59
  const cutoff = now - (24 * 60 * 60 * 1000); // 24 hours ago
58
60
  this.history = this.history.filter(m => m.time > cutoff);
@@ -82,6 +84,7 @@ class ServerStats {
82
84
  this.taskMap.set(name, info);
83
85
  info.frequency = frequency;
84
86
  info.state = "Started";
87
+ info.status = "started"
85
88
  }
86
89
 
87
90
  task(name, state) {
@@ -89,6 +92,25 @@ class ServerStats {
89
92
  if (info) {
90
93
  info.date = Date.now();
91
94
  info.state = state;
95
+ info.status = 'working';
96
+ }
97
+ }
98
+
99
+ taskDone(name, state) {
100
+ let info = this.taskMap.get(name);
101
+ if (info) {
102
+ info.date = Date.now();
103
+ info.state = state;
104
+ info.status = 'resting';
105
+ }
106
+ }
107
+
108
+ taskError(name, state) {
109
+ let info = this.taskMap.get(name);
110
+ if (info) {
111
+ info.date = Date.now();
112
+ info.state = state;
113
+ info.status = 'error';
92
114
  }
93
115
  }
94
116
 
@@ -96,17 +118,19 @@ class ServerStats {
96
118
  if (this.taskMap.size == 0) {
97
119
  return "";
98
120
  }
99
- let html = '<table class="grid"><tr style="background-color: #EEEEEE"><th colspan="4">Background Tasks</th></tr>';
100
- html += "<tr><th>Task</th><th>Status</th><th>Frequency</th><th>Last Seen</th></tr>";
121
+ let html = '<table class="grid">';
122
+ html += "<tr><th>Background Task</th><th>Status</th><th>Frequency</th><th>Last Seen</th></tr>";
101
123
  for (let m of this.taskMap.keys()) {
102
- html += "<tr><td>";
124
+ let mm = this.taskMap.get(m);
125
+ let color = this.getTaskColor(mm.status);
126
+ html += `<tr style="background-color: ${color}"><td>`;
103
127
  html += escape(m);
104
128
  html += "</td><td>";
105
- html += escape(this.taskMap.get(m).state);
129
+ html += escape(mm.state);
106
130
  html += "</td><td>";
107
- html += this.taskMap.get(m).frequency;
131
+ html += mm.frequency;
108
132
  html += "</td><td>";
109
- html += Utilities.formatDuration(this.taskMap.get(m).date, Date.now());
133
+ html += Utilities.formatDuration(mm.date, Date.now());
110
134
  html += "</td></tr>";
111
135
  }
112
136
  html += "</table>";
@@ -128,5 +152,14 @@ class ServerStats {
128
152
  return { idle, total };
129
153
  }
130
154
 
155
+ getTaskColor(status) {
156
+ switch (status) {
157
+ case "started": return "LightGrey";
158
+ case "working": return "LightGreen";
159
+ case "resting": return "White";
160
+ case "error": return "LightRed";
161
+ default: return "DarkBlue"; // should not happen
162
+ }
163
+ }
131
164
  }
132
165
  module.exports = ServerStats;
package/tx/README.md CHANGED
@@ -201,7 +201,7 @@ Loads LOINC from a SQLite database file.
201
201
  ```
202
202
 
203
203
  The filename is downloaded from the base URL if not cached. Database files must be in the server's proprietary format.
204
- The file is built by importing LOINC (to be documented)
204
+ The file is built by importing LOINC (see [documentation](importers/readme.md))
205
205
 
206
206
  #### `rxnorm` - RxNorm
207
207
 
@@ -211,7 +211,7 @@ Loads RxNorm drug terminology from a SQLite database file.
211
211
  - rxnorm:rxnorm_02032025-a.db
212
212
  ```
213
213
 
214
- The file is built by importing RxNorm (to be documented)
214
+ The file is built by importing RxNorm (see [documentation](importers/readme.md))
215
215
 
216
216
  #### `ndc` - NDC (National Drug Code)
217
217
 
@@ -228,7 +228,7 @@ Loads FDA UNII codes from a SQLite database file.
228
228
  ```yaml
229
229
  - unii:unii_20240622.db
230
230
  ```
231
- The file is built by importing UNII (to be documented)
231
+ The file is built by importing UNII (see [documentation](importers/readme.md))
232
232
 
233
233
  #### `snomed` - SNOMED CT
234
234
 
@@ -254,7 +254,7 @@ Common edition identifiers:
254
254
  - `nl` - Netherlands
255
255
  - `ips` - IPS (International Patient Summary) Free Set
256
256
 
257
- The file is built by importing SNOMED CT (to be documented)
257
+ The file is built by importing SNOMED CT (see [documentation](importers/readme.md))
258
258
 
259
259
 
260
260
  #### `cpt` - CPT (Current Procedural Terminology)
@@ -267,7 +267,7 @@ Loads CPT codes from a SQLite database file.
267
267
 
268
268
  **Note:** CPT is copyrighted by the American Medical Association. Ensure you have appropriate licensing.
269
269
 
270
- The file is built by importing CPT (to be documented)
270
+ The file is built by importing CPT (see [documentation](importers/readme.md))
271
271
 
272
272
  #### `omop` - OMOP Vocabularies
273
273
 
@@ -276,7 +276,7 @@ Loads OMOP (Observational Medical Outcomes Partnership) vocabulary mappings from
276
276
  ```yaml
277
277
  - omop:omop_v20250227.db
278
278
  ```
279
- The file is built by importing OMOP (to be documented)
279
+ The file is built by importing OMOP (see [documentation](importers/readme.md))
280
280
 
281
281
  #### `npm` - FHIR NPM Packages
282
282
 
package/tx/cs/cs-api.js CHANGED
@@ -9,6 +9,9 @@ const {validateParameter, validateArrayParameter} = require("../../library/utili
9
9
  const {I18nSupport} = require("../../library/i18nsupport");
10
10
  const {VersionUtilities} = require("../../library/version-utilities");
11
11
 
12
+ /**
13
+ * For documentation, see cs-api.md
14
+ */
12
15
  class FilterExecutionContext {
13
16
  filters = [];
14
17
  forIterate = false;