fhirsmith 0.18.1 → 1.0.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 (214) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +190 -9
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +331 -33
  15. package/ips/jurisdictions.js +65 -0
  16. package/ips/nationality.js +120 -0
  17. package/ips/pipeline.js +131 -17
  18. package/ips/presenter.js +1 -1
  19. package/ips/profile-editor.js +283 -4
  20. package/ips/readme.md +170 -20
  21. package/ips/render.js +769 -109
  22. package/ips/scripts.js +46 -0
  23. package/ips/store.js +123 -11
  24. package/ips/structure.js +247 -0
  25. package/ips/terminology.js +1 -1
  26. package/library/body-limit.js +22 -0
  27. package/library/cache-generation.js +15 -0
  28. package/library/cgroup-memory.js +133 -1
  29. package/library/cluster-counts.js +121 -0
  30. package/library/cluster-events.js +153 -0
  31. package/library/cluster-metrics.js +193 -0
  32. package/library/cluster-status.js +468 -0
  33. package/library/config-reader.js +96 -0
  34. package/library/daily-report.js +100 -2
  35. package/library/html-server.js +27 -1
  36. package/library/i18nsupport.js +23 -1
  37. package/library/keep-alive.js +18 -0
  38. package/library/languages.js +17 -3
  39. package/library/logger.js +31 -0
  40. package/library/memory-guard.js +428 -0
  41. package/library/native-memory.js +52 -0
  42. package/library/npm-audit.js +51 -25
  43. package/library/openapi-doc.js +7 -5
  44. package/library/package-manager.js +8 -0
  45. package/library/role.js +197 -0
  46. package/library/server-credentials.js +169 -0
  47. package/library/sqlite-utilities.js +59 -0
  48. package/library/ssrf-guard.js +100 -0
  49. package/library/static-response-stats.js +16 -0
  50. package/library/version-utilities.js +50 -0
  51. package/library/worker-count.js +192 -0
  52. package/nginx.md +30 -11
  53. package/npmprojector/indexer.js +18 -10
  54. package/package.json +11 -8
  55. package/packages/feed-check-page.js +117 -0
  56. package/packages/feed-check.js +279 -0
  57. package/packages/npm-check.js +57 -3
  58. package/packages/npm-signing.js +219 -0
  59. package/packages/openapi.yaml +207 -1
  60. package/packages/package-crawler.js +322 -41
  61. package/packages/packages-template.html +6 -8
  62. package/packages/packages.js +604 -57
  63. package/packages/readme.md +113 -1
  64. package/publisher/publisher-template.html +2 -5
  65. package/publisher/publisher.js +121 -110
  66. package/publisher/readme.md +75 -0
  67. package/registry/api.js +179 -1
  68. package/registry/crawler.js +86 -108
  69. package/registry/documentation/api.md +68 -2
  70. package/registry/documentation/configuration.md +34 -0
  71. package/registry/documentation/internals.md +4 -1
  72. package/registry/documentation/readme.md +3 -2
  73. package/registry/model.js +8 -0
  74. package/registry/openapi.yaml +84 -0
  75. package/registry/registry-template.html +5 -8
  76. package/registry/registry.js +58 -6
  77. package/root-bare-template.html +7 -4
  78. package/root-template.html +5 -8
  79. package/server.js +398 -198
  80. package/shl/shl.js +38 -37
  81. package/static/a11y/a11y-check.js +832 -0
  82. package/static/a11y/axe.min.js +12 -0
  83. package/static/assets/css/bootstrap-fhir.css +15 -15
  84. package/static/assets/images/flags/au.svg +18 -0
  85. package/static/assets/images/flags/unknown.svg +11 -0
  86. package/static/assets/images/flags/us.svg +63 -0
  87. package/static/assets/images/icon_audit.png +0 -0
  88. package/static/assets/images/icon_clinical.png +0 -0
  89. package/static/assets/images/icon_document.png +0 -0
  90. package/static/assets/images/icon_external.png +0 -0
  91. package/static/assets/images/icon_medication.png +0 -0
  92. package/static/assets/images/icon_organization.png +0 -0
  93. package/static/assets/images/icon_other.png +0 -0
  94. package/static/assets/images/icon_person.png +0 -0
  95. package/static/assets/images/icon_section.png +0 -0
  96. package/stats-db.js +45 -0
  97. package/stats.js +197 -10
  98. package/testing/testing-template.html +5 -8
  99. package/token/nginx_passport_setup.md +1 -1
  100. package/token/security_guide.md +1 -1
  101. package/token/token-template.html +2 -5
  102. package/token/token.js +40 -36
  103. package/translations/Messages.properties +19 -0
  104. package/tx/README.md +158 -4
  105. package/tx/closure/closure-store.js +126 -3
  106. package/tx/cm/cm-database.js +69 -57
  107. package/tx/cm/cm-package.js +50 -16
  108. package/tx/cs/cs-api.js +284 -19
  109. package/tx/cs/cs-api.md +41 -4
  110. package/tx/cs/cs-areacode.js +1 -1
  111. package/tx/cs/cs-country.js +17 -5
  112. package/tx/cs/cs-cpt.js +33 -16
  113. package/tx/cs/cs-cs.js +199 -24
  114. package/tx/cs/cs-currency.js +1 -1
  115. package/tx/cs/cs-icd11.js +21 -13
  116. package/tx/cs/cs-icf.js +22 -3
  117. package/tx/cs/cs-lang.js +2 -2
  118. package/tx/cs/cs-loinc.js +193 -67
  119. package/tx/cs/cs-mimetypes.js +12 -2
  120. package/tx/cs/cs-ndc.js +40 -25
  121. package/tx/cs/cs-omop.js +54 -29
  122. package/tx/cs/cs-rxnorm.js +300 -196
  123. package/tx/cs/cs-snomed.js +861 -95
  124. package/tx/cs/cs-ucum.js +14 -4
  125. package/tx/cs/cs-unii.js +18 -14
  126. package/tx/cs/cs-virtual.js +210 -0
  127. package/tx/ecosystem-resolver.js +413 -0
  128. package/tx/html/cluster-dash-metrics.liquid +26 -0
  129. package/tx/html/cluster-home-metrics.liquid +31 -0
  130. package/tx/html/cluster-metrics-script.liquid +251 -0
  131. package/tx/html/codesystem-operations.liquid +3 -3
  132. package/tx/html/ecl-panel.liquid +1 -1
  133. package/tx/html/external-metrics.liquid +17 -0
  134. package/tx/html/operations-form.liquid +17 -17
  135. package/tx/html/search-form.liquid +11 -11
  136. package/tx/html/status-line.liquid +58 -0
  137. package/tx/html/status-panel.liquid +140 -0
  138. package/tx/html/status-tasks.liquid +25 -0
  139. package/tx/html/tx-template.html +2 -5
  140. package/tx/importers/atc-to-fhir.js +17 -16
  141. package/tx/importers/export-icd11-codesystem.js +3 -2
  142. package/tx/importers/import-icd11.js +4 -3
  143. package/tx/importers/import-loinc.module.js +118 -56
  144. package/tx/importers/import-ndc.module.js +56 -44
  145. package/tx/importers/import-rxnorm.module.js +118 -79
  146. package/tx/importers/import-sct.module.js +125 -61
  147. package/tx/importers/import-unii.module.js +37 -36
  148. package/tx/importers/subset-loinc.module.js +49 -48
  149. package/tx/importers/subset-rxnorm.module.js +29 -28
  150. package/tx/importers/terminal.js +18 -0
  151. package/tx/importers/tx-import-base.js +13 -12
  152. package/tx/importers/tx-import-settings.js +5 -4
  153. package/tx/importers/tx-import.js +53 -52
  154. package/tx/library/canonical-resource.js +49 -0
  155. package/tx/library/codesystem.js +94 -5
  156. package/tx/library/designations.js +45 -2
  157. package/tx/library/extensions.js +13 -7
  158. package/tx/library/implicit-valuesets.js +66 -0
  159. package/tx/library/renderer.js +77 -21
  160. package/tx/library/sqlite-utilities.js +2 -0
  161. package/tx/library/tho-precedence.js +40 -0
  162. package/tx/library/ucum-parsers.js +11 -1
  163. package/tx/library/wait-for-cache.js +43 -0
  164. package/tx/library.js +321 -22
  165. package/tx/openapi-operations.js +6 -4
  166. package/tx/operation-context.js +460 -99
  167. package/tx/params.js +25 -0
  168. package/tx/problems.js +3 -5
  169. package/tx/provider.js +314 -67
  170. package/tx/resource-cache-sqlite.js +1050 -0
  171. package/tx/sct/documentation.md +45 -21
  172. package/tx/sct/ecl.js +81 -17
  173. package/tx/sct/editions.js +39 -0
  174. package/tx/sct/expressions.js +10 -1
  175. package/tx/sct/load-config.js +107 -0
  176. package/tx/sct/mmap.js +109 -0
  177. package/tx/sct/structures.js +175 -15
  178. package/tx/sct/text-index.js +5 -1
  179. package/tx/syndication/syndication-feed.js +2 -2
  180. package/tx/tx-html.js +44 -8
  181. package/tx/tx.fhir.org.yml +8 -8
  182. package/tx/tx.js +282 -80
  183. package/tx/usage-tracker.js +39 -11
  184. package/tx/vs/vs-api.js +19 -0
  185. package/tx/vs/vs-database.js +386 -62
  186. package/tx/vs/vs-package.js +44 -17
  187. package/tx/vs/vs-vsac.js +649 -101
  188. package/tx/workers/batch-validate.js +37 -10
  189. package/tx/workers/batch.js +6 -1
  190. package/tx/workers/cache-control.js +15 -4
  191. package/tx/workers/closure.js +156 -13
  192. package/tx/workers/compare.js +8 -5
  193. package/tx/workers/expand.js +1173 -289
  194. package/tx/workers/metadata.js +35 -0
  195. package/tx/workers/search.js +53 -1
  196. package/tx/workers/supplement-filters.js +145 -0
  197. package/tx/workers/translate.js +169 -4
  198. package/tx/workers/validate.js +223 -119
  199. package/tx/workers/worker.js +123 -31
  200. package/tx/xversion/xv-valueset.js +7 -4
  201. package/utilities/submit-test-report.js +3 -1
  202. package/utilities/tx-stress-test.js +482 -0
  203. package/vcl/readme.md +37 -0
  204. package/vcl/vcl-parser.js +15 -0
  205. package/xig/readme.md +52 -0
  206. package/xig/xig-template.html +5 -8
  207. package/xig/xig.js +238 -422
  208. package/changelog-template.md +0 -22
  209. package/registry/test-runner.js +0 -237
  210. package/release.sh +0 -80
  211. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  212. package/tsconfig.eslint.json +0 -9
  213. package/tx/html/dash-metrics.liquid +0 -147
  214. package/tx/html/home-metrics.liquid +0 -225
package/server.js CHANGED
@@ -6,34 +6,83 @@
6
6
  // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
7
7
  //
8
8
 
9
+ // Production unless told otherwise: Express and other libraries check NODE_ENV (in development
10
+ // mode Express's error pages include stack traces), and a server where nobody set it should get
11
+ // the safe behaviour. `npm run dev` (node --watch) runs in development mode
12
+ process.env.NODE_ENV ??= process.execArgv.includes('--watch') ? 'development' : 'production';
13
+
14
+ // Logging starts before anything else, so that a server that fails to start (bad config.json, a
15
+ // missing dependency, an unwritable folder) says why in the log as well as on stderr. It starts with
16
+ // the logger defaults; config.logging is applied once config.json has been read
17
+ const folders = require('./library/folder-setup');
18
+ const Logger = require('./library/logger');
19
+ let bootLog;
20
+ try {
21
+ bootLog = Logger.getInstance().child({ module: 'server' });
22
+ } catch (error) {
23
+ console.error('Failed to start logging:', error.message);
24
+ process.exit(1);
25
+ }
26
+ bootLog.info(`FHIRsmith starting (PID ${process.pid}, Node.js ${process.version}), data directory ${folders.dataDir()}`);
27
+
28
+ // Until startup is complete, an uncaught exception is fatal: log it and exit (the logger flushes on exit).
29
+ // Replaced by the long-running handlers once the server is set up
30
+ function startupFailure(error) {
31
+ console.error('FATAL - startup failed:', error);
32
+ bootLog.error('FATAL - startup failed: ' + (error?.message || error), error);
33
+ process.exit(1);
34
+ }
35
+ process.on('uncaughtException', startupFailure);
36
+
9
37
  const express = require('express');
10
38
  const cors = require('cors');
11
39
  const path = require('path');
12
40
  const fs = require('fs');
13
41
  const os = require('os');
14
- const v8 = require('v8');
15
- const folders = require('./library/folder-setup'); // <-- ADD: load early
16
42
  const { statSync, readdirSync } = require('fs');
17
43
  const escape = require('escape-html');
18
44
  const { resolveWithin } = require('./library/path-safety');
19
- const { readCgroupMemoryLimit } = require('./library/cgroup-memory');
45
+ const { readProcessMemory } = require('./library/cgroup-memory');
46
+ const memoryGuard = require('./library/memory-guard');
47
+ const { applyKeepAliveTimeouts } = require('./library/keep-alive');
48
+ const { countStaticResponses } = require('./library/static-response-stats');
49
+ const { trimNativeMemory } = require('./library/native-memory');
20
50
  const { NpmAudit } = require('./library/npm-audit');
21
51
  const Mailer = require('./library/mailer');
22
52
  const DailyReport = require('./library/daily-report');
53
+ const { maxBodyBytes } = require('./library/body-limit');
23
54
 
24
- // Load configuration BEFORE logger
55
+ // Load configuration (from the data directory)
25
56
  let config;
57
+ const configPath = folders.filePath('config.json');
58
+ bootLog.info(`Reading configuration from ${configPath}`);
26
59
  try {
27
- const configPath = folders.filePath('config.json'); // <-- CHANGE: config now in data dir
28
- const configData = fs.readFileSync(configPath, 'utf8');
29
- config = JSON.parse(configData);
60
+ // JSON, with // and /* */ comments allowed (library/config-reader.js)
61
+ config = require('./library/config-reader').readConfig(configPath);
62
+ // the server block is optional (every setting in it has a default)
63
+ config.server = config.server || {};
30
64
  } catch (error) {
31
- console.error('Failed to load configuration:', error.message);
65
+ console.error(`Failed to load configuration from ${configPath}:`, error.message);
66
+ bootLog.error(`Failed to load configuration from ${configPath}: ${error.message}`);
32
67
  process.exit(1);
33
68
  }
34
69
 
35
- const Logger = require('./library/logger');
36
- const serverLog = Logger.getInstance(config.logging || {}).child({ module: 'server' });
70
+ // --role=master|tx-worker (cluster.md). With no role this is null and the configuration is
71
+ // untouched: every `if (role)` below is skipped
72
+ const { parseRole, applyRole, describeRole } = require('./library/role');
73
+ let role;
74
+ try {
75
+ role = parseRole(process.argv.slice(2), process.env);
76
+ if (role) {
77
+ applyRole(config, role);
78
+ }
79
+ } catch (error) {
80
+ console.error(error.message);
81
+ process.exit(1);
82
+ }
83
+
84
+ Logger.getInstance().configure(config.logging || {});
85
+ const serverLog = Logger.getInstance().child({ module: 'server' });
37
86
  const packageJson = require('./package.json');
38
87
 
39
88
  // Startup banner
@@ -43,11 +92,10 @@ serverLog.info(`========================================`);
43
92
  serverLog.info(`FHIRsmith v${packageJson.version} starting (PID ${process.pid})`);
44
93
  serverLog.info(`Node.js ${process.version} on ${os.type()} ${os.release()} (${os.arch()})`);
45
94
  serverLog.info(`Memory: ${freeMemGB} GB free / ${totalMemGB} GB total`);
46
- {
47
- const cg = readCgroupMemoryLimit();
48
- serverLog.info(cg.limit > 0
49
- ? `Memory limit: ${(cg.limit / 1024 / 1024 / 1024).toFixed(1)} GB (from ${cg.source})`
50
- : 'Memory limit: none found in cgroup - RSS guard disabled');
95
+ memoryGuard.configure(config.server.memory || {});
96
+ serverLog.info(memoryGuard.describe());
97
+ if (role) {
98
+ serverLog.info(describeRole(role, config));
51
99
  }
52
100
  serverLog.info(`Data directory: ${folders.dataDir()}`);
53
101
  serverLog.info(`========================================`);
@@ -72,12 +120,17 @@ const IpsModule = require('./ips/ips.js');
72
120
 
73
121
  const htmlServer = require('./library/html-server');
74
122
  const ServerStats = require("./stats");
123
+ const { connectMetrics, graphData, externalSummary } = require('./library/cluster-metrics');
124
+ const { connectCounts } = require('./library/cluster-counts');
125
+ const { connectClusterEvents } = require('./library/cluster-events');
126
+ const { connectStatus, ownFigures, clusterView, processView, storageView, clusterSummary, processSummary, STATUS_INTERVAL_MS } = require('./library/cluster-status');
75
127
  const {Liquid} = require("liquidjs");
76
128
  const FolderModule = require("./folder/folder");
77
129
  const ExtensionTrackerModule = require("./extension-tracker/extension-tracker");
78
130
 
79
131
  htmlServer.useLog(serverLog);
80
132
  htmlServer.setSponsorMessage(config.sponsorMessage ? config.sponsorMessage : '');
133
+ htmlServer.setAccessibilityPanel(config.accessibility?.checkPanel === true);
81
134
 
82
135
  const app = express();
83
136
 
@@ -90,18 +143,42 @@ const app = express();
90
143
  const DEFAULT_TRUST_PROXY = 'loopback, linklocal, uniquelocal';
91
144
  app.set('trust proxy', config.server.trustProxy ?? DEFAULT_TRUST_PROXY);
92
145
 
93
- const PORT = process.env.PORT || config.server.port || 3000;
146
+ const PORT = (role && role.port) || process.env.PORT || config.server.port || 3000;
147
+ // with a role, loopback unless server.cluster.bindAddress says otherwise (cluster.md); with
148
+ // none, every interface, as before
149
+ const HOST = role ? require('./library/role').bindAddress(config) : null;
94
150
 
95
151
  // Middleware
152
+ // The requests this process has in flight, for the status panel (library/cluster-status.js)
153
+ let inFlight = 0;
154
+ app.use((req, res, next) => {
155
+ inFlight++;
156
+ let done = false;
157
+ const finished = () => {
158
+ if (!done) {
159
+ done = true;
160
+ inFlight--;
161
+ }
162
+ };
163
+ res.once('finish', finished);
164
+ res.once('close', finished);
165
+ next();
166
+ });
167
+ // Refuse new requests (503) while memory is near the limit - first, so a refused request
168
+ // doesn't even have its body parsed. /dashboard stays reachable so the state can be seen.
169
+ app.use(memoryGuard.middleware({ log: serverLog, exempt: ['/dashboard'],
170
+ onReject: role ? () => stats?.countRefusedRequest() : undefined }));
96
171
  // The body parsers, for every module but those that read their own bodies with their own
97
172
  // limits and checks (the ips module: a document is read only once its submitter's token has
98
173
  // been checked, and no bigger than the module's maxSize)
99
174
  const OWN_BODIES = ['/ips'];
100
175
  const ownsBody = (req) => OWN_BODIES.some(p => req.path === p || req.path.startsWith(p + '/'));
101
176
  const unlessOwnBody = (parser) => (req, res, next) => (ownsBody(req) ? next() : parser(req, res, next));
102
- app.use(unlessOwnBody(express.raw({ type: 'application/fhir+json', limit: '50mb' })));
103
- app.use(unlessOwnBody(express.raw({ type: 'application/fhir+xml', limit: '50mb' })));
104
- app.use(unlessOwnBody(express.json({ limit: '50mb' })));
177
+ // Bodies up to server.maxBodyMB (default 50); a bigger one gets a 413
178
+ const maxBody = maxBodyBytes(config.server);
179
+ app.use(unlessOwnBody(express.raw({ type: 'application/fhir+json', limit: maxBody })));
180
+ app.use(unlessOwnBody(express.raw({ type: 'application/fhir+xml', limit: maxBody })));
181
+ app.use(unlessOwnBody(express.json({ limit: maxBody })));
105
182
  app.use((req, res, next) => {
106
183
  const requestId = req.headers['x-request-id'];
107
184
  if (requestId) {
@@ -120,6 +197,19 @@ app.use(cors(config.server.cors));
120
197
  const modules = {};
121
198
 
122
199
  let stats = null;
200
+ let disconnectMetrics = () => {};
201
+ let disconnectCounts = null;
202
+ let disconnectClusterEvents = () => {};
203
+ let disconnectStatus = () => {};
204
+ // The graphs on / and /dashboard, one component for every server (library/cluster-metrics.js):
205
+ // on the master under the launcher, every process it reports; otherwise this process's own
206
+ // samples as one series, with its memory guard's refusal threshold. `level`: the graphs' titles'
207
+ function metricsTemplate(page, level = 4) {
208
+ const label = !role ? 'This process' : `${role.role === 'master' ? 'Master' : 'Worker'} ${config.modules.tx.instanceCode}`;
209
+ const guard = memoryGuard._state;
210
+ // (legend: false - statusContent puts the graphs' key under "More")
211
+ return [`cluster-${page}`, { metricsJson: graphData(stats, label, Date.now(), { rejectAt: guard.rejectAt, group: Boolean(guard.group) }), level, legend: false }];
212
+ }
123
213
  // periodic npm advisory check of the installed packages - see library/npm-audit.js
124
214
  let npmAudit = null;
125
215
  // the daily report, emailed at midnight when "email" and "dailyReport.to" are configured - see library/daily-report.js
@@ -128,6 +218,18 @@ let dailyReport = null;
128
218
  // Initialize modules based on configuration
129
219
  async function initializeModules() {
130
220
  stats = new ServerStats(config.stats, serverLog);
221
+ if (role) {
222
+ disconnectMetrics = connectMetrics(stats);
223
+ // a worker's request counts go to the master's statistics (library/cluster-counts.js)
224
+ disconnectCounts = connectCounts(stats, process, { role: role.role });
225
+ // and on the master, what the launcher saw, for the daily report (library/cluster-events.js)
226
+ disconnectClusterEvents = connectClusterEvents(stats, process, { role: role.role });
227
+ // this process's figures for the status panel, and on the master the launcher's snapshot
228
+ // of them all (library/cluster-status.js)
229
+ disconnectStatus = connectStatus(stats, process, { role: role.role,
230
+ intervalMs: Number(config.server.cluster?.statusSeconds ?? STATUS_INTERVAL_MS / 1000) * 1000,
231
+ figures: () => ownFigures(stats, { inFlight: () => inFlight, refusing: () => memoryGuard.shouldReject() }) });
232
+ }
131
233
  npmAudit = new NpmAudit(config.npmAudit || {}, Logger.getInstance().child({ module: 'npm-audit' }), __dirname, stats);
132
234
 
133
235
  // Initialize SHL module
@@ -334,6 +436,154 @@ async function loadTemplates() {
334
436
  }
335
437
  }
336
438
 
439
+ // The request count in /dashboard/status.json: on the master under the launcher, what its
440
+ // statistics hold, its own and the workers' it has merged (library/cluster-counts.js), which
441
+ // arrive up to a minute late; otherwise this process's
442
+ function requestCounts() {
443
+ if (role && role.role === 'master' && process.send) {
444
+ return {
445
+ count: stats.requestCount + stats.mergedRequestCount,
446
+ detail: `master: ${stats.requestCount}, workers: ${stats.mergedRequestCount}, static: ${stats.staticRequestCount}`
447
+ };
448
+ }
449
+ return { count: stats.requestCount, detail: `static: ${stats.staticRequestCount}` };
450
+ }
451
+
452
+ // The status panel's view (library/cluster-status.js, tx/html/status-panel.liquid): the
453
+ // cluster's on the master under the launcher, once its first snapshot has come; else this
454
+ // process's own, labelled as what it is
455
+ function statusView() {
456
+ const [uptime, uptimeSub] = uptimeParts(Date.now() - stats.startTime);
457
+ const host = { used: os.totalmem() - os.freemem(), total: os.totalmem() };
458
+ const cluster = clusterStatus();
459
+ if (cluster) {
460
+ return clusterView(cluster, { uptime, uptimeSub, host });
461
+ }
462
+ // a fresh enough measure, as the guard takes it
463
+ memoryGuard.shouldReject();
464
+ const g = memoryGuard._state;
465
+ const own = readProcessMemory('self');
466
+ const minutes = (Date.now() - stats.startTime) / 60000;
467
+ const code = role ? config.modules.tx.instanceCode : null;
468
+ return processView({
469
+ figures: ownFigures(stats, { inFlight: () => inFlight, refusing: () => memoryGuard.shouldReject() }),
470
+ uptime, uptimeSub, host, static: stats.staticRequestCount,
471
+ avgPerMin: minutes >= 1 ? stats.requestCount / minutes : null,
472
+ anon: own ? own.anon : null,
473
+ guard: { measure: g.sampleRss, limit: g.limit, rejectAt: g.rejectAt, abortAt: g.abortAt, rejectFraction: g.rejectFraction,
474
+ abortFraction: g.abortFraction, group: Boolean(g.group), fileBacked: g.excludeFileBacked }
475
+ }, !role ? { heading: 'Server', meta: ['one process'] } : {
476
+ heading: `${role.role === 'master' ? 'Master' : 'Worker'} ${code}`,
477
+ meta: role.role === 'master' && process.send ? ['this process', 'waiting for the launcher\'s first snapshot'] : ['this process only']
478
+ });
479
+ }
480
+
481
+ // Every background task, in the status panel's table style (tx/html/status-panel.liquid), in a
482
+ // box of its own that scrolls rather than the page; under "More"
483
+ function backgroundTasks(level) {
484
+ const table = stats.taskDetails();
485
+ return table ? `<section class="fs-status fs-status-tasks"><h${level} class="fs-status-head">All background tasks</h${level}>` +
486
+ `<div class="fs-status-scroll">${table}</div></section>` : '';
487
+ }
488
+
489
+ // The status line at the top of / and /dashboard (tx/html/status-line.liquid): requests, uptime
490
+ // and heap, the cluster's on the master under the launcher, else this process's; and the problems
491
+ function statusLine() {
492
+ const [uptime, uptimeSub] = uptimeParts(Date.now() - stats.startTime);
493
+ // low disk space is a problem, and also on its own line above "More", in red (see statusContent)
494
+ const logs = logFigures();
495
+ const disk = logs ? logs.disk : null;
496
+ const failedTasks = stats.failedTasks();
497
+ const other = npmAudit && npmAudit.enabled && npmAudit.checkedAt && npmAudit.isSerious() ? [`npm audit: ${npmAudit.summary()}`] : [];
498
+ const cluster = clusterStatus();
499
+ if (cluster) {
500
+ return clusterSummary(cluster, { uptime, uptimeSub, disk, failedTasks, other });
501
+ }
502
+ // a fresh enough measure, as the guard takes it
503
+ memoryGuard.shouldReject();
504
+ const g = memoryGuard._state;
505
+ const minutes = (Date.now() - stats.startTime) / 60000;
506
+ return processSummary({
507
+ figures: ownFigures(stats, { inFlight: () => inFlight, refusing: () => memoryGuard.shouldReject() }),
508
+ uptime, uptimeSub, avgPerMin: minutes >= 1 ? stats.requestCount / minutes : null,
509
+ guard: { measure: g.sampleRss, rejectAt: g.rejectAt }, disk, failedTasks, other
510
+ });
511
+ }
512
+
513
+ // under this share of the disk free, a warning in red above "More"
514
+ const DISK_LOW = 0.1;
515
+
516
+ // Low disk space (the disk the logs are on), as a warning in red; '' when there is enough
517
+ function diskWarning() {
518
+ const logs = logFigures();
519
+ const disk = logs ? logs.disk : null;
520
+ if (!disk || !(disk.total > 0) || disk.free >= DISK_LOW * disk.total) {
521
+ return '';
522
+ }
523
+ const gb = (bytes) => (bytes / 2 ** 30).toFixed(1);
524
+ return `<span style="color:#b00"><strong><span aria-hidden="true">⚠ </span>Disk space low:</strong> ` +
525
+ `${gb(disk.free)} GB free of ${gb(disk.total)} GB (${Math.round(100 * disk.free / disk.total)}%)</span>`;
526
+ }
527
+
528
+ // The top of / and /dashboard: the status line, the graphs, the background tasks at work, one
529
+ // line of what the server needs looking at (`notes`: html, with low disk space added), then under
530
+ // "More" the graphs' key, the status panel, storage, every background task and `more` (html).
531
+ // Headings start at `level`, the page's sections' own.
532
+ async function statusContent(liquid, page, level, { notes = [], details = '', more = '' } = {}) {
533
+ let content = await liquid.renderFile('status-line', { line: statusLine(), level });
534
+ content += await liquid.renderFile(...metricsTemplate(page, level + 1));
535
+ content += await liquid.renderFile('status-tasks', { tasks: stats.taskSummary(), level });
536
+ const line = [...notes, diskWarning()].filter(Boolean);
537
+ if (line.length) {
538
+ content += `<div id="npm-audit" style="font-size:13px;margin:0 0 8px"><p style="margin:0 0 4px">${line.join(' · ')}</p>${details}</div>`;
539
+ }
540
+ content += `<details class="fs-status-more"><summary><h${level}>More</h${level}>: the graphs' key, every process, memory, storage, every background task</summary>`;
541
+ // the graphs' key (tx/html/cluster-metrics-script.liquid puts it here)
542
+ content += '<div id="serverMetricsLegend" class="server-metrics-legend"></div>';
543
+ content += await liquid.renderFile('status-panel', { view: statusView(), level: level + 1 });
544
+ const external = externalStatus();
545
+ if (external) {
546
+ content += await liquid.renderFile('external-metrics', { external, level: level + 1 });
547
+ }
548
+ content += await liquid.renderFile('status-panel', { view: storageStatusView(), level: level + 1 });
549
+ content += backgroundTasks(level + 1);
550
+ content += more;
551
+ content += '</details>';
552
+ return content;
553
+ }
554
+
555
+ // The external requests - those that had to ask another server (tx ecosystem resolution,
556
+ // ServerStats.countExternalRequest) - in the graphs' samples (every process's, on the master
557
+ // under the launcher): {count, average, span} for tx/html/external-metrics.liquid, or null if
558
+ // there have been none in them
559
+ function externalStatus(now = Date.now()) {
560
+ const summary = externalSummary(stats, now);
561
+ if (!summary) {
562
+ return null;
563
+ }
564
+ const { count, average, hours } = summary;
565
+ return {
566
+ count: count.toLocaleString(),
567
+ average: average >= 1000 ? `${(average / 1000).toFixed(1)} s` : `${Math.round(average)} ms`,
568
+ span: hours >= 23.5 ? '24 hours' : hours >= 1.5 ? `${Math.round(hours)} hours` : hours >= 1 || Math.round(hours * 60) <= 1 ? (hours >= 1 ? 'hour' : 'minute') : `${Math.round(hours * 60)} minutes`
569
+ };
570
+ }
571
+
572
+ // The storage tiles below the graphs: closure tables, logs, disk
573
+ function storageStatusView() {
574
+ const logs = logFigures();
575
+ const closure = stats.closureStats();
576
+ return storageView({
577
+ heading: 'Storage', closure, logs: logs && logs.files !== null ? logs : null, disk: logs && logs.disk
578
+ });
579
+ }
580
+
581
+ // The launcher's snapshot of every process (library/cluster-status.js), on the master under
582
+ // the launcher once one has come; else null, and the table is this process's
583
+ function clusterStatus() {
584
+ return role && role.role === 'master' && stats.clusterStatus ? stats.clusterStatus : null;
585
+ }
586
+
337
587
  async function buildRootPageContent() {
338
588
  stats.requestCount++;
339
589
  let content = npmAudit ? npmAudit.renderBanner() : '';
@@ -431,6 +681,7 @@ async function buildRootPageContent() {
431
681
  content += '</li>';
432
682
  }
433
683
  content += '</ul>';
684
+ content += '</li>';
434
685
  }
435
686
 
436
687
 
@@ -454,72 +705,12 @@ async function buildRootPageContent() {
454
705
 
455
706
  content += '<hr/>';
456
707
 
457
- // Calculate uptime
458
- const uptimeMs = Date.now() - stats.startTime;
459
- const uptimeStr = uptimeText(uptimeMs);
460
-
461
- // Memory usage
462
- const memUsage = process.memoryUsage();
463
- const heapStats = v8.getHeapStatistics();
464
-
465
- // V8 heap: used vs limit
466
- const v8UsedMB = (memUsage.heapUsed / 1024 / 1024).toFixed(0);
467
- const v8LimitMB = (heapStats.heap_size_limit / 1024 / 1024).toFixed(0);
468
- const v8PCT = (memUsage.heapUsed * 100) / heapStats.heap_size_limit;
469
-
470
- // Process RSS vs cgroup limit (or system total)
471
- const rssMB = (memUsage.rss / 1024 / 1024).toFixed(0);
472
- const memLimit = readCgroupMemoryLimit().limit || os.totalmem();
473
- const memLimitMB = (memLimit / 1024 / 1024).toFixed(0);
474
- const processPCT = (memUsage.rss * 100) / memLimit;
475
-
476
- // System memory
477
- const totalMemMB = (os.totalmem() / 1024 / 1024).toFixed(0);
478
- const usedMemMB = ((os.totalmem() - os.freemem()) / 1024 / 1024).toFixed(0);
479
- const sysMemPCT = ((os.totalmem() - os.freemem()) * 100) / os.totalmem();
480
-
481
- // Average requests per minute
482
- const uptimeMinutesTotal = uptimeMs / 60000;
483
- const avgReqPerMin = uptimeMinutesTotal > 0 ? (stats.requestCount / uptimeMinutesTotal).toFixed(1) : '0.0';
484
-
485
- content += '<table class="grid">';
486
- content += '<tr>';
487
- content += `<td><strong>Uptime:</strong> ${escape(uptimeStr)}</td>`;
488
- content += `<td><strong>Request Count:</strong> ${stats.requestCount} (static: ${stats.staticRequestCount})</td>`;
489
- content += `<td><strong>Avg Requests/min:</strong> ${avgReqPerMin}</td>`;
490
- content += '</tr>';
491
- content += '<tr>';
492
- content += `<td style="background-color:${pctColor(v8PCT)}"><strong>V8 Memory:</strong> ${v8UsedMB} MB of ${v8LimitMB} MB (${v8PCT.toFixed(0)}%)</td>`;
493
- content += `<td style="background-color:${pctColor(processPCT)}"><strong>Process Memory:</strong> ${rssMB} MB of ${memLimitMB} MB (${processPCT.toFixed(0)}%)</td>`;
494
- content += `<td style="background-color:${pctColor(sysMemPCT)}"><strong>System Memory:</strong> ${usedMemMB} MB of ${totalMemMB} MB (${sysMemPCT.toFixed(0)}%)</td>`;
495
- content += '</tr>';
496
- content += '<tr>';
497
- content += `<td><strong>Expansion Cache:</strong> ${stats.expansionItems()} items</td>`;
498
- content += `<td><strong>Client Cache:</strong> ${stats.clientCaches()} caches / ${stats.clientConcepts()} concepts</td>`;
499
- content += `<td><strong>Max Client Cache:</strong> ${stats.maxClientCaches()} caches / ${stats.maxClientConcepts()} concepts</td>`;
500
- content += '</tr>';
501
- const closure = stats.closureStats();
502
- if (closure) {
503
- content += '<tr>';
504
- content += `<td><strong>Closure Tables:</strong> ${closure.tables} tables` +
505
- (closure.retentionDays ? ` (unused for ${closure.retentionDays} days = deleted)` : '') + `</td>`;
506
- content += `<td><strong>Closure Entries:</strong> ${closure.entries} entries / ${closure.concepts} concepts</td>`;
507
- content += `<td><strong>Closure Database:</strong> ${closure.bytes === null ? 'in memory' : formatBytes(closure.bytes)}</td>`;
508
- content += '</tr>';
509
- }
510
- content += getLogStats();
511
- content += '</table>';
512
-
513
- // ===== Metrics Graphs =====
708
+ // the status line, the graphs and the tasks at work; the rest under "More" (library/cluster-status.js)
514
709
  const liquid = new Liquid({
515
710
  root: path.join(__dirname, 'tx', 'html'),
516
711
  extname: '.liquid'
517
712
  });
518
- content += await liquid.renderFile('home-metrics', {
519
- historyJson: JSON.stringify(stats.history),
520
- startTime: stats.startTime
521
- });
522
- content += stats.taskDetails();
713
+ content += await statusContent(liquid, 'home-metrics', 3);
523
714
 
524
715
  content += '</div>';
525
716
  return content;
@@ -531,11 +722,16 @@ process.on('unhandledRejection', (reason, promise) => {
531
722
  serverLog.error('Unhandled Rejection:', reason);
532
723
  });
533
724
 
534
- process.on('uncaughtException', (error) => {
535
- console.error('FATAL - Uncaught Exception:', error);
536
- serverLog.error('FATAL - Uncaught Exception:', error);
537
- process.exitCode = 1;
538
- });
725
+ // Once the server is listening, startup is over: an uncaught exception is logged, and the
726
+ // server carries on (startupFailure, which exits, covers everything until then - see startServer)
727
+ function startupComplete() {
728
+ process.off('uncaughtException', startupFailure);
729
+ process.on('uncaughtException', (error) => {
730
+ console.error('FATAL - Uncaught Exception:', error);
731
+ serverLog.error('FATAL - Uncaught Exception:', error);
732
+ process.exitCode = 1;
733
+ });
734
+ }
539
735
 
540
736
  app.get('/', async (req, res) => {
541
737
  // If an override index.html exists, serve it instead of the FHIRsmith home page
@@ -597,20 +793,14 @@ app.get('/', async (req, res) => {
597
793
  app.get('/fhirsmith', (req, res) => serveFhirsmithHome(req, res));
598
794
 
599
795
  // Serve static files
600
- // Count static file hits separately from API/page requests
601
- app.use((req, res, next) => {
602
- res.on('finish', () => {
603
- if (res.statusCode >= 200 && res.statusCode < 300) {
604
- stats.staticRequestCount++;
605
- }
606
- });
607
- next();
608
- });
796
+ // Count static file hits separately from API/page requests: only responses the static
797
+ // handlers below send themselves, not a later route's response to a request that fell through
798
+ const staticFiles = express.Router();
609
799
  if (config.server?.webBase) {
610
800
  const overrideDir = path.resolve(config.server.webBase);
611
801
  // Anything under overrideDir may be served; nothing outside it may - see
612
802
  // library/path-safety.js for why path.join is not enough on its own.
613
- app.use((req, res, next) => {
803
+ staticFiles.use((req, res, next) => {
614
804
  const filePath = resolveWithin(overrideDir, req.path);
615
805
  if (filePath === null) {
616
806
  return next();
@@ -627,7 +817,36 @@ if (config.server?.webBase) {
627
817
  });
628
818
  });
629
819
  }
630
- app.use(express.static(path.join(__dirname, 'static')));
820
+ staticFiles.use(express.static(path.join(__dirname, 'static')));
821
+ app.use(countStaticResponses(staticFiles, () => { stats.staticRequestCount++; }));
822
+
823
+ // The status panel's figures, machine-readable (cluster.md, "Status"). Under /dashboard, so the
824
+ // memory guard never refuses it. On the master under the launcher: the launcher's snapshot of
825
+ // every process (library/cluster-status.js); otherwise this process's own figures
826
+ app.get('/dashboard/status.json', (req, res) => {
827
+ stats.requestCount++;
828
+ const requests = requestCounts();
829
+ const counts = { count: requests.count, detail: requests.detail };
830
+ const cluster = clusterStatus();
831
+ let body;
832
+ if (cluster) {
833
+ body = { scope: 'cluster', generatedAt: Date.now(), requests: counts, ...cluster };
834
+ } else {
835
+ const guard = memoryGuard._state;
836
+ const own = readProcessMemory('self');
837
+ body = {
838
+ scope: 'process', generatedAt: Date.now(), requests: counts,
839
+ role: role ? role.role : null, code: role ? config.modules.tx.instanceCode : null,
840
+ process: { ...ownFigures(stats, { inFlight: () => inFlight, refusing: () => memoryGuard.shouldReject() }),
841
+ uptimeMs: Date.now() - stats.startTime, rss: process.memoryUsage().rss,
842
+ anon: own ? own.anon : null, file: own ? own.file : null },
843
+ memory: { limit: guard.limit, limitSource: guard.source, rejectAt: guard.rejectAt, abortAt: guard.abortAt,
844
+ rejectFraction: guard.rejectFraction, abortFraction: guard.abortFraction, group: Boolean(guard.group) }
845
+ };
846
+ }
847
+ res.setHeader('Cache-Control', 'no-store');
848
+ res.json(body);
849
+ });
631
850
 
632
851
  // Dashboard endpoint - server name, stats, graphs, and background tasks (no modules list)
633
852
  app.get('/dashboard', async (req, res) => {
@@ -639,52 +858,21 @@ app.get('/dashboard', async (req, res) => {
639
858
  }
640
859
 
641
860
  const startTime = Date.now();
642
- // Calculate uptime
643
- const uptimeMs = Date.now() - stats.startTime;
644
- const uptimeStr = uptimeText(uptimeMs);
645
-
646
- // Memory usage
647
- const memUsage = process.memoryUsage();
648
- const heapStats = v8.getHeapStatistics();
649
- const v8PCT = (memUsage.heapUsed * 100) / heapStats.heap_size_limit; // % of V8 heap limit used
650
-
651
- // Process RSS as % of cgroup memory limit (or system total as fallback)
652
- const memLimit = readCgroupMemoryLimit().limit || os.totalmem();
653
- const processPCT = (memUsage.rss * 100) / memLimit;
654
-
655
- const totalMemBytes = os.totalmem();
656
- const freeMemBytes = os.freemem();
657
- const sysMemPCT = ((totalMemBytes - freeMemBytes) * 100) / totalMemBytes; // % of system memory used
658
-
659
- const memMaxPCT = Math.max(v8PCT, processPCT, sysMemPCT);
660
-
661
- const fstats = fs.statfsSync(folders.logsDir());
662
- const diskPCT = 100 - ((fstats.bavail * 100) / fstats.blocks); // % of disk used
663
-
664
- let content = '<style>table.grid{margin-bottom:10px;border:1px solid black;margin-right:auto}table.grid th,table.grid td{border:1px solid silver;padding:3px 7px 2px;font-size:12px;line-height:1.4em;font-family:verdana;vertical-align:top}table.grid th{font-weight:bold}table.grid td{font-weight:normal}</style>';
665
- content += '<table class="grid">';
666
- content += '<tr>';
667
- content += `<td><strong>Uptime:</strong> ${escape(uptimeStr)}</td>`;
668
- content += `<td><strong>Request Count:</strong> ${stats.requestCount} (static: ${stats.staticRequestCount})</td>`;
669
- content += `<td style="background-color:${pctColor(memMaxPCT)}"><strong>Memory:</strong> ${v8PCT.toFixed(0)}% V8, ${processPCT.toFixed(0)}% Process, ${sysMemPCT.toFixed(0)}% System</td>`;
670
- content += `<td style="background-color:${pctColor(diskPCT)}"><strong>Disk:</strong> ${diskPCT.toFixed(0)}%</td>`;
671
- content += '</tr>';
672
- content += '</table>';
673
-
674
- // ===== Metrics Graphs =====
861
+ // the status line, the graphs and the tasks at work; the rest under "More" (library/cluster-status.js)
675
862
  const liquid = new Liquid({
676
863
  root: path.join(__dirname, 'tx', 'html'),
677
864
  extname: '.liquid'
678
865
  });
679
- content += await liquid.renderFile('dash-metrics', {
680
- historyJson: JSON.stringify(stats.history),
681
- startTime: stats.startTime
866
+ // the background tasks' table (the bare template has no fhir.css)
867
+ let content = '<style>table.grid{margin-bottom:10px;border:1px solid black;margin-right:auto}table.grid th,table.grid td{border:1px solid silver;padding:3px 7px 2px;font-size:12px;line-height:1.4em;font-family:verdana;vertical-align:top}table.grid th{font-weight:bold}table.grid td{font-weight:normal}</style>';
868
+ // the page's title is its h1 (root-bare-template.html), so its sections are h2
869
+ // the release and npm audit on one line above "More" (the advice and the findings, when there
870
+ // are any, after it); the data folder under "More"
871
+ content += await statusContent(liquid, 'dash-metrics', 2, {
872
+ notes: npmAudit ? [npmAudit.renderLine()] : [],
873
+ details: npmAudit ? npmAudit.renderFindings() : '',
874
+ more: `<p>Data: ${escape(folders.dataDir())}</p>`
682
875
  });
683
- content += stats.taskDetails();
684
- if (npmAudit) {
685
- content += npmAudit.renderDashboard();
686
- }
687
- content += `<p>Data: ${folders.dataDir()}</p>`;
688
876
 
689
877
  content = '<div class="row mb-4"><div class="col-12">' + content + '</div></div>';
690
878
 
@@ -704,23 +892,17 @@ app.get('/dashboard', async (req, res) => {
704
892
  }
705
893
  });
706
894
 
707
- function formatBytes(bytes) {
708
- if (bytes < 1024) {
709
- return `${bytes} bytes`;
710
- }
711
- if (bytes < 1024 * 1024) {
712
- return `${(bytes / 1024).toFixed(1)} KB`;
713
- }
714
- if (bytes < 1024 * 1024 * 1024) {
715
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
716
- }
717
- return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
718
- }
719
-
720
895
  /**
721
896
  * "3d 4h 5m 6s" for this run, followed by the proportion of time the server has
722
897
  * been up over all its recorded runs, when the statistics database is keeping that.
723
898
  */
899
+ // [the uptime, "97.3% overall" or null], for the Uptime tile
900
+ function uptimeParts(uptimeMs) {
901
+ const text = uptimeText(uptimeMs);
902
+ const m = / \((.*)\)$/.exec(text);
903
+ return m ? [text.substring(0, m.index), m[1]] : [text, null];
904
+ }
905
+
724
906
  function uptimeText(uptimeMs) {
725
907
  const uptimeSeconds = Math.floor(uptimeMs / 1000);
726
908
  const uptimeDays = Math.floor(uptimeSeconds / 86400);
@@ -741,15 +923,6 @@ function uptimeText(uptimeMs) {
741
923
  return uptimeStr;
742
924
  }
743
925
 
744
- function pctColor(pct) {
745
- // Gradient from green (#deffe0) at 0% to red (#ffd3d1) at 100%
746
- const t = Math.max(0, Math.min(100, pct)) / 100;
747
- const r = Math.round(222 + 33 * t); // 222 -> 255
748
- const g = Math.round(255 - 44 * t); // 255 -> 211
749
- const b = Math.round(224 - 15 * t); // 224 -> 209
750
- return `rgb(${r}, ${g}, ${b})`;
751
- }
752
-
753
926
  // Health check endpoint
754
927
  app.get('/health', async (req, res) => {
755
928
  const healthStatus = {
@@ -781,45 +954,29 @@ app.get('/health', async (req, res) => {
781
954
  * Get log directory statistics: file count, total size, and disk space info
782
955
  * @returns {string} HTML table row(s) with log stats
783
956
  */
784
- function getLogStats() {
957
+ // The logs and the disk they are on, for the storage tiles: files and bytes null if the logs
958
+ // can't be read, disk null if the disk can't
959
+ function logFigures() {
785
960
  const logDir = folders.logsDir();
786
-
961
+ let disk = null;
962
+ try {
963
+ // statfs available in Node 18.15+
964
+ const fstats = fs.statfsSync(logDir);
965
+ disk = { free: fstats.bavail * fstats.bsize, total: fstats.blocks * fstats.bsize };
966
+ } catch {
967
+ // unavailable
968
+ }
787
969
  try {
788
970
  const files = readdirSync(logDir).filter(f => f.endsWith('.log'));
789
- let totalSize = 0;
971
+ let bytes = 0;
790
972
  for (const file of files) {
791
- totalSize += statSync(path.join(logDir, file)).size;
792
- }
793
-
794
- const sizeMB = (totalSize / 1024 / 1024).toFixed(2);
795
-
796
- let diskInfo = '';
797
- try {
798
- // statfs available in Node 18.15+
799
- const fstats = fs.statfsSync(logDir);
800
- const blockSize = fstats.bsize;
801
- const freeSpace = fstats.bavail * blockSize;
802
- const totalSpace = fstats.blocks * blockSize;
803
- const freeGB = (freeSpace / 1024 / 1024 / 1024).toFixed(2);
804
- const totalGB = (totalSpace / 1024 / 1024 / 1024).toFixed(2);
805
- diskInfo = `<td><strong>Disk Space:</strong> ${freeGB} GB of ${totalGB} GB</td>`;
806
- } catch {
807
- diskInfo = '<td><strong>Disk Space:</strong> unavailable</td>';
973
+ bytes += statSync(path.join(logDir, file)).size;
808
974
  }
809
-
810
- // Log rotation limit from logger config
975
+ // how many files the logger keeps (its rotation)
811
976
  const loggerOpts = Logger.getInstance().options;
812
- const maxFiles = loggerOpts.file.maxFiles;
813
- const maxSize = loggerOpts.file.maxSize;
814
- const limitInfo = `${maxFiles} files × ${maxSize} each`;
815
-
816
- return '<tr>'
817
- + `<td><strong>Existing Logs:</strong> ${files.length} (${sizeMB} MB)</td>`
818
- + `<td><strong>Retention Policy:</strong> ${limitInfo}</td>`
819
- + diskInfo
820
- + '</tr>';
821
- } catch (e) {
822
- return `<tr><td colspan="3"><strong>Logs:</strong> unable to read (${e.message})</td></tr>`;
977
+ return { files: files.length, bytes, kept: loggerOpts.file.maxFiles, disk };
978
+ } catch {
979
+ return { files: null, bytes: null, kept: null, disk };
823
980
  }
824
981
  }
825
982
 
@@ -835,11 +992,33 @@ async function startServer() {
835
992
  throw error;
836
993
  });
837
994
 
995
+ // With a role: loading the library leaves 0.6-0.7 GB free but resident in the C allocator;
996
+ // give it back before taking requests, since N+1 processes share one limit (cluster.md)
997
+ if (role && memoryGuard._state.trimAllocator) {
998
+ const before = process.memoryUsage.rss();
999
+ if (trimNativeMemory() !== null) {
1000
+ serverLog.info(`Memory: malloc_trim after loading - RSS ${memoryGuard.gb(before)} GB -> ${memoryGuard.gb(process.memoryUsage.rss())} GB`);
1001
+ }
1002
+ }
1003
+
838
1004
  // Start server
839
- app.listen(PORT, () => {
1005
+ const listening = () => {
840
1006
  stats.markStarted(packageJson.version);
841
- serverLog.info(`=== Server running on http://localhost:${PORT} ===`);
842
- });
1007
+ serverLog.info(HOST ? `=== Server running on http://${HOST.includes(':') ? `[${HOST}]` : HOST}:${PORT} (server.cluster.bindAddress) ===`
1008
+ : `=== Server running on http://localhost:${PORT} ===`);
1009
+ startupComplete();
1010
+ if (role && process.send) {
1011
+ // to the launcher (cluster.js): ready, from this process itself
1012
+ try {
1013
+ process.send({ type: require('./library/role').READY, pid: process.pid });
1014
+ } catch {
1015
+ // the launcher has gone; the disconnect handler below shuts this process down
1016
+ }
1017
+ }
1018
+ };
1019
+ const server = HOST ? app.listen(PORT, HOST, listening) : app.listen(PORT, listening);
1020
+ // idle connections stay open longer than nginx keeps them (see library/keep-alive.js)
1021
+ applyKeepAliveTimeouts(server);
843
1022
  npmAudit.start();
844
1023
  dailyReport = new DailyReport(config.dailyReport, {
845
1024
  log: Logger.getInstance().child({ module: 'daily-report' }),
@@ -848,7 +1027,9 @@ async function startServer() {
848
1027
  mailer: new Mailer(config.email, Logger.getInstance().child({ module: 'email' })),
849
1028
  npmAudit: npmAudit,
850
1029
  version: packageJson.version,
851
- serverId: config.hostName
1030
+ serverId: config.hostName,
1031
+ // under the launcher: the report has a paragraph on the cluster
1032
+ cluster: Boolean(role && role.role === 'master' && process.send)
852
1033
  });
853
1034
  dailyReport.start();
854
1035
  if (modules.packages && config.modules?.packages?.enabled) {
@@ -884,6 +1065,10 @@ async function shutdown() {
884
1065
  }
885
1066
  shuttingDown = true;
886
1067
  serverLog.info('\nShutting down server...');
1068
+ if (disconnectCounts) {
1069
+ // a worker's last request counts, while the launcher still listens
1070
+ await disconnectCounts.flush();
1071
+ }
887
1072
  if (npmAudit) {
888
1073
  npmAudit.stop();
889
1074
  }
@@ -904,11 +1089,26 @@ async function shutdown() {
904
1089
  }
905
1090
  }
906
1091
  stats.finishStats();
1092
+ disconnectMetrics();
1093
+ disconnectStatus();
1094
+ if (disconnectCounts) {
1095
+ disconnectCounts();
1096
+ }
1097
+ disconnectClusterEvents();
907
1098
  serverLog.info('Server shutdown complete');
908
1099
  process.exit(0);
909
1100
  }
910
1101
  process.on('SIGINT', () => void shutdown());
911
1102
  process.on('SIGTERM', () => void shutdown());
1103
+ // Started by the launcher (cluster.js, an IPC channel): when the launcher goes, even
1104
+ // SIGKILLed, so does this process, rather than hold its port against the next launcher's
1105
+ // workers. With no one left to SIGKILL a shutdown that hangs, exit after 30 s regardless
1106
+ if (role && process.connected) {
1107
+ process.once('disconnect', () => {
1108
+ setTimeout(() => process.exit(1), 30000).unref();
1109
+ void shutdown();
1110
+ });
1111
+ }
912
1112
 
913
1113
  async function serveFhirsmithHome(req, res) {
914
1114
  // Check if client wants HTML response
@@ -993,4 +1193,4 @@ async function serveFhirsmithHome(req, res) {
993
1193
  startServer().catch((err) => {
994
1194
  serverLog.error('Fatal error during startup:', err);
995
1195
  process.exit(1);
996
- });
1196
+ });