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
@@ -0,0 +1,192 @@
1
+ /**
2
+ * How many tx-workers the launcher starts when server.cluster.workers is not given
3
+ * (cluster.md, "How many workers").
4
+ *
5
+ * The inputs: the vCPUs this process may use (os.availableParallelism()), the physical cores
6
+ * among them (Linux sysfs topology; elsewhere taken to be the vCPUs), and a cgroup v2 cpu.max
7
+ * quota, which caps the cores. A rule turns them into a number; the launcher then lowers it if
8
+ * the processes won't fit in memory. The rule is the engine's: 'classic' here, and an engine
9
+ * whose processes use the CPU differently registers its own (registerWorkerRule) and makes it
10
+ * the default, or server.cluster.workerRule names one.
11
+ *
12
+ * @module library/worker-count
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+
19
+ /**
20
+ * A CPU list as the kernel writes it ("0-3,8,10-11") to the CPU numbers.
21
+ * @param {string} text
22
+ * @returns {number[]}
23
+ */
24
+ function parseCpuList(text) {
25
+ const cpus = [];
26
+ for (const part of String(text).trim().split(',')) {
27
+ const m = /^(\d+)(?:-(\d+))?$/.exec(part.trim());
28
+ if (!m) {
29
+ continue;
30
+ }
31
+ const lo = Number(m[1]);
32
+ const hi = m[2] !== undefined ? Number(m[2]) : lo;
33
+ for (let c = lo; c <= hi; c++) {
34
+ cpus.push(c);
35
+ }
36
+ }
37
+ return cpus;
38
+ }
39
+
40
+ function readText(file) {
41
+ try {
42
+ return fs.readFileSync(file, 'utf8');
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * The physical cores among the CPUs this process may run on (its affinity mask), from
50
+ * /sys/devices/system/cpu/cpuN/topology/core_cpus_list (thread_siblings_list on older kernels).
51
+ * @param {Object} [opts] - for tests: other locations
52
+ * @param {string} [opts.sysCpu='/sys/devices/system/cpu']
53
+ * @param {string} [opts.procStatus='/proc/self/status']
54
+ * @returns {{cpus: number, cores: number}|null} null where the topology can't be read
55
+ */
56
+ function readTopology(opts = {}) {
57
+ const sysCpu = opts.sysCpu || '/sys/devices/system/cpu';
58
+ const status = readText(opts.procStatus || '/proc/self/status');
59
+ const m = status && /^Cpus_allowed_list:\s*(\S+)/m.exec(status);
60
+ const allowed = m ? m[1] : readText(path.join(sysCpu, 'online'));
61
+ const cpus = allowed ? parseCpuList(allowed) : [];
62
+ if (!cpus.length) {
63
+ return null;
64
+ }
65
+ const cores = new Set();
66
+ for (const cpu of cpus) {
67
+ const dir = path.join(sysCpu, 'cpu' + cpu, 'topology');
68
+ const siblings = readText(path.join(dir, 'core_cpus_list')) ?? readText(path.join(dir, 'thread_siblings_list'));
69
+ if (siblings === null) {
70
+ return null;
71
+ }
72
+ cores.add(parseCpuList(siblings).join(','));
73
+ }
74
+ return { cpus: cpus.length, cores: cores.size };
75
+ }
76
+
77
+ /**
78
+ * A cgroup v2 cpu.max quota on this process's cgroup or one enclosing it, in CPUs (the smallest).
79
+ * @param {Object} [opts]
80
+ * @param {string} [opts.procFile='/proc/self/cgroup']
81
+ * @param {string} [opts.cgroupRoot='/sys/fs/cgroup']
82
+ * @returns {number|null} null: none
83
+ */
84
+ function readCpuQuota(opts = {}) {
85
+ const cgroupRoot = opts.cgroupRoot || '/sys/fs/cgroup';
86
+ const proc = readText(opts.procFile || '/proc/self/cgroup') || '';
87
+ const line = proc.split('\n').find(l => l.startsWith('0::'));
88
+ let p = path.posix.normalize(line ? line.slice(3).trim() || '/' : '/');
89
+ let quota = null;
90
+ for (;;) {
91
+ const raw = readText(path.join(cgroupRoot, p, 'cpu.max'));
92
+ const m = raw && /^(\d+)\s+(\d+)/.exec(raw.trim());
93
+ if (m && Number(m[2]) > 0) {
94
+ const q = Number(m[1]) / Number(m[2]);
95
+ if (quota === null || q < quota) {
96
+ quota = q;
97
+ }
98
+ }
99
+ if (p === '/' || p === '.') {
100
+ break;
101
+ }
102
+ p = path.posix.dirname(p);
103
+ }
104
+ return quota;
105
+ }
106
+
107
+ /**
108
+ * The inputs to a rule.
109
+ * @param {Object} [opts]
110
+ * @param {number} [opts.vcpu] - default os.availableParallelism()
111
+ * @param {number} [opts.cores] - server.cluster.cores: overrides the detected physical cores
112
+ * @param {Object} [opts.topology], [opts.quota] - for tests (see readTopology, readCpuQuota)
113
+ * @returns {{vcpu: number, cores: number, coresFrom: string, smt: number, quota: number|null}}
114
+ */
115
+ function cpuInputs(opts = {}) {
116
+ const vcpu = Math.max(1, Math.floor(opts.vcpu ?? (os.availableParallelism ? os.availableParallelism() : os.cpus().length)));
117
+ const topo = opts.topology !== undefined ? opts.topology : readTopology(opts.sysfs);
118
+ const quota = opts.quota !== undefined ? opts.quota : readCpuQuota(opts.cgroup);
119
+ let cores;
120
+ let coresFrom;
121
+ if (opts.cores !== undefined && opts.cores !== null) {
122
+ cores = Math.max(1, Math.floor(Number(opts.cores)));
123
+ coresFrom = 'server.cluster.cores';
124
+ } else if (topo) {
125
+ cores = topo.cores;
126
+ coresFrom = 'physical';
127
+ } else {
128
+ cores = vcpu;
129
+ coresFrom = 'vCPU (no topology)';
130
+ }
131
+ const smt = topo && topo.cores ? Math.round(topo.cpus / topo.cores) : 1;
132
+ if (quota !== null) {
133
+ cores = Math.max(1, Math.min(cores, Math.ceil(quota)));
134
+ }
135
+ return { vcpu, cores, coresFrom, smt, quota };
136
+ }
137
+
138
+ // the rules: (inputs) => { workers, formula }. Measured in cluster.md, "How many workers"
139
+ const RULES = {
140
+ classic: ({ cores, vcpu }) => ({
141
+ workers: Math.min(cores, Math.round(0.6 * vcpu)),
142
+ formula: `min(${cores}, round(0.6×${vcpu}))`
143
+ })
144
+ };
145
+ let defaultRule = 'classic';
146
+
147
+ /**
148
+ * Add a rule; with makeDefault, it is used when server.cluster.workerRule doesn't name one.
149
+ * @param {string} name
150
+ * @param {function({vcpu: number, cores: number}): {workers: number, formula: string}} rule
151
+ * @param {boolean} [makeDefault]
152
+ */
153
+ function registerWorkerRule(name, rule, makeDefault = false) {
154
+ RULES[name] = rule;
155
+ if (makeDefault) {
156
+ defaultRule = name;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * The default number of workers, before the memory check.
162
+ * @param {Object} [opts] - cpuInputs' options, and rule (a name)
163
+ * @returns {{workers: number, rule: string, formula: string, inputs: Object}}
164
+ */
165
+ function defaultWorkers(opts = {}) {
166
+ const rule = opts.rule || defaultRule;
167
+ if (!RULES[rule]) {
168
+ throw new Error(`server.cluster.workerRule '${rule}' is not one of ${Object.keys(RULES).join(', ')}`);
169
+ }
170
+ const inputs = cpuInputs(opts);
171
+ const { workers, formula } = RULES[rule](inputs);
172
+ return { workers: Math.max(1, workers), rule, formula, inputs };
173
+ }
174
+
175
+ /**
176
+ * The inputs and the decision, in one line for the launcher's log.
177
+ * @param {{workers: number, rule: string, formula: string, inputs: Object}} d - from defaultWorkers
178
+ * @param {number} workers - what starts
179
+ * @param {string} memory - e.g. 'memory fits 11'
180
+ * @param {boolean} given - server.cluster.workers was given
181
+ */
182
+ function describeDecision(d, workers, memory, given) {
183
+ const i = d.inputs;
184
+ const cores = i.coresFrom === 'physical' ? `${i.cores} physical cores (${i.vcpu} vCPU, SMT ${i.smt})`
185
+ : `${i.cores} cores from ${i.coresFrom} (${i.vcpu} vCPU)`;
186
+ const quota = i.quota === null ? 'none' : `${+i.quota.toFixed(2)} CPU`;
187
+ const why = given ? `given (server.cluster.workers; ${d.rule} rule would give ${d.workers})`
188
+ : `${d.rule} rule ${d.formula}` + (workers < d.workers ? ` = ${d.workers}, lowered to fit memory` : '');
189
+ return `workers ${workers}: ${cores}, quota ${quota}, ${memory} → ${why}`;
190
+ }
191
+
192
+ module.exports = { parseCpuList, readTopology, readCpuQuota, cpuInputs, defaultWorkers, registerWorkerRule, describeDecision, RULES };
package/nginx.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  FHIRsmith has no built-in TLS, rate limiting or access control. In production it always runs
4
4
  behind a reverse proxy, which handles those things. This page covers nginx, which is what the
5
- public servers (tx.fhir.org, packages.fhir.org and so on) use.
5
+ public servers (tx.fhir.org, packages.fhir.org and so on) use, and is what all the documentation is based
6
+ on. We have no experience with any other reverse proxy.
6
7
 
7
8
  * [General configuration](#general-configuration) - what nginx should hand across to the server
8
9
  * [Rate limiting](#rate-limiting) - approaches that suit terminology traffic
@@ -47,6 +48,8 @@ them:
47
48
  * `Accept-Language` - the display language and the language of error messages
48
49
  * `X-Cache-Id` - the terminology cache protocol (see [Routing to multiple servers](#routing-to-multiple-servers))
49
50
 
51
+ Note: the terminology ecosystem test cases also use the custom header `X-TOO-COSTLY-THRESHOLD` for a few tests.
52
+
50
53
  ### Trusting the proxy
51
54
 
52
55
  By default FHIRsmith believes the `X-Forwarded-*` headers only when the request comes from
@@ -79,9 +82,11 @@ upstream fhirsmith {
79
82
 
80
83
  ### Sizes and timeouts
81
84
 
82
- * **Body size.** FHIRsmith accepts request bodies up to 50 MB. Set `client_max_body_size`
83
- to match: if nginx allows more, oversized requests get past nginx and then fail with an
84
- unhelpful error from the server.
85
+ * **Body size.** FHIRsmith accepts request bodies up to `server.maxBodyMB` (default 50 MB).
86
+ nginx's `client_max_body_size` is applied first: if it is lower, nginx refuses a bigger
87
+ body with its own 413 HTML page before FHIRsmith sees it, so set it at or above `maxBodyMB`
88
+ to get FHIRsmith's OperationOutcome; if it is higher, `maxBodyMB` applies. Requests of
89
+ over 100 MB have been seen, caused by client-side issues: choose both limits with that in mind.
85
90
  * **Timeouts.** Some terminology operations are slow, particularly large SNOMED CT
86
91
  expansions and batch validations. The public servers use 600s for
87
92
  `proxy_read_timeout`/`proxy_send_timeout`. If you set a shorter timeout, nginx returns
@@ -237,6 +242,14 @@ limit_conn_zone $limit_key zone=perip:10m;
237
242
 
238
243
  ## Routing to multiple servers
239
244
 
245
+ The instructions here are for using multiple independent FHIRsmith instances. Alternatively,
246
+ you can use a single instance with multiple servers in a [cluster](cluster.md). If you're doing that,
247
+ you can skip this section, and just set up the cluster as a single server from the point of view of nginx.
248
+
249
+ Note: Only the terminology module should run in the pool. Modules that keep their own state
250
+ (`publisher`, `packages`, `shl`, `token`) belong on a single instance, with their own
251
+ location or server name routed to it.
252
+
240
253
  Most terminology operations keep no state between requests, so a pool of identical
241
254
  FHIRsmith instances could share traffic in any order. The exception is the **terminology
242
255
  cache** (`$cache-control`). A client starts a cache on one server, loads resources into it,
@@ -262,7 +275,7 @@ Give each instance a short code in its config.json:
262
275
  ```
263
276
 
264
277
  The server prefixes every cache-id it issues with its code and a dot, e.g.
265
- `tx1.4f0c2d1e-…`. Codes must be unique within the pool, and 1-16 letters and digits.
278
+ `tx1.mhp4x2k9.4f0c2d1e-…` (`<code>.<start>.<uuid>`, below). Codes must be unique within the pool, and 1-16 letters and digits.
266
279
  A client never has to understand the prefix: to the client, the cache-id stays an opaque
267
280
  string.
268
281
 
@@ -324,7 +337,9 @@ Notes:
324
337
  * Routing only works on the **header**. The older `cache-id` request parameter sits in the
325
338
  request body, where nginx can't see it. Current clients (fhir-core) send the header.
326
339
  * A restarted instance loses its caches. Clients recover by starting a new one, just as
327
- they do after a cache expires.
340
+ they do after a cache expires. A cache-id is `<code>.<start>.<uuid>`, where `<start>` is
341
+ when the instance started, so the restarted instance reports an id it issued before the
342
+ restart as "lost to a server restart", and one it never issued as never issued.
328
343
 
329
344
  ### What must be the same on every instance
330
345
 
@@ -336,10 +351,6 @@ are kept per instance, which is fine:
336
351
  * expansion caches (each instance warms its own)
337
352
  * usage statistics (each instance reports its own share)
338
353
 
339
- Only the terminology module should run in the pool. Modules that keep their own state
340
- (`publisher`, `packages`, `shl`, `token`) belong on a single instance, with their own
341
- location or server name routed to it.
342
-
343
354
  ## Managing closure
344
355
 
345
356
  `$closure` stores state on the server between client sessions. (It is defined on ConceptMap,
@@ -348,6 +359,8 @@ a named closure table, adds concepts to it over days or months, and asks for the
348
359
  since the version it last saw. The table lives in the database of the instance that holds
349
360
  it.
350
361
 
362
+ ### Closure on multiple servers
363
+
351
364
  **Route every `$closure` request to one designated instance.**
352
365
 
353
366
  ```nginx
@@ -375,7 +388,7 @@ server {
375
388
 
376
389
  `$closure` traffic is light, so a single instance is not a bottleneck.
377
390
 
378
- ### Why this doesn't conflict with cache routing
391
+ #### Why this doesn't conflict with cache routing
379
392
 
380
393
  It might look as if a `$closure` request could carry an `X-Cache-Id` that belongs to a
381
394
  different instance. That doesn't cause a problem, because closure can't depend on the
@@ -397,3 +410,9 @@ the cache ends, and the table would silently change meaning. So `$closure` ignor
397
410
  about. An outage is better than that: clients see a failure and retry later.
398
411
  * **Moving the designated instance** means moving its closure database with it, then
399
412
  changing the upstream.
413
+
414
+ ### A master and tx workers on one machine
415
+
416
+ When the server is running as a [cluster](cluster.md), the nginx configuration says nothing
417
+ about closure, nor about the cluster generally.
418
+
@@ -4,6 +4,7 @@
4
4
  //
5
5
 
6
6
  const fhirpath = require('fhirpath');
7
+ const indexerLog = require('../library/logger').getInstance().child({ module: 'npmprojector' });
7
8
 
8
9
  class FHIRIndexer {
9
10
  constructor(fhirModel = null) {
@@ -85,9 +86,9 @@ class FHIRIndexer {
85
86
  }
86
87
  }
87
88
  } catch (err) {
88
- console.log(err);
89
- // FHIRPath evaluation failed - skip this param for this resource
90
- // Logged at debug level to avoid noise
89
+ // FHIRPath evaluation failed - skip this param for this resource. Logged at debug level
90
+ // to avoid noise
91
+ indexerLog.debug(`search parameter ${paramName}: ${err.message}`);
91
92
  }
92
93
  }
93
94
  }
@@ -249,7 +250,7 @@ class FHIRIndexer {
249
250
  const trimmedValue = value.trim();
250
251
 
251
252
  switch (paramType) {
252
- case 'string':
253
+ case 'string': {
253
254
  // String search: case-insensitive starts-with by default
254
255
  const lowerValue = trimmedValue.toLowerCase();
255
256
  for (const [indexedValue, ids] of paramIndex) {
@@ -258,22 +259,25 @@ class FHIRIndexer {
258
259
  }
259
260
  }
260
261
  break;
262
+ }
261
263
 
262
- case 'token':
264
+ case 'token': {
263
265
  // Token search: exact match on system|code, |code, or code
264
266
  const tokenMatches = this.matchToken(trimmedValue, paramIndex);
265
267
  tokenMatches.forEach(id => matchingIds.add(id));
266
268
  break;
269
+ }
267
270
 
268
- case 'reference':
271
+ case 'reference': {
269
272
  // Reference search: exact match
270
273
  const refIds = paramIndex.get(trimmedValue);
271
274
  if (refIds) {
272
275
  refIds.forEach(id => matchingIds.add(id));
273
276
  }
274
277
  break;
278
+ }
275
279
 
276
- case 'date':
280
+ case 'date': {
277
281
  // Simple prefix matching for dates (no range support as specified)
278
282
  for (const [indexedValue, ids] of paramIndex) {
279
283
  if (indexedValue.startsWith(trimmedValue)) {
@@ -281,9 +285,10 @@ class FHIRIndexer {
281
285
  }
282
286
  }
283
287
  break;
288
+ }
284
289
 
285
290
  case 'number':
286
- case 'quantity':
291
+ case 'quantity': {
287
292
  // Numeric equality
288
293
  const numValue = parseFloat(trimmedValue);
289
294
  if (!isNaN(numValue)) {
@@ -293,21 +298,24 @@ class FHIRIndexer {
293
298
  }
294
299
  }
295
300
  break;
301
+ }
296
302
 
297
- case 'uri':
303
+ case 'uri': {
298
304
  // Exact match for URI
299
305
  const uriIds = paramIndex.get(trimmedValue);
300
306
  if (uriIds) {
301
307
  uriIds.forEach(id => matchingIds.add(id));
302
308
  }
303
309
  break;
310
+ }
304
311
 
305
- default:
312
+ default: {
306
313
  // Default: exact match (case-insensitive)
307
314
  const defaultIds = paramIndex.get(trimmedValue.toLowerCase());
308
315
  if (defaultIds) {
309
316
  defaultIds.forEach(id => matchingIds.add(id));
310
317
  }
318
+ }
311
319
  }
312
320
  }
313
321
 
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.18.1",
4
- "txVersion": "1.10.0",
3
+ "version": "1.0.0",
4
+ "txVersion": "1.11.1-snapshot",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
7
7
  "engines": {
8
8
  "node": ">=24"
9
9
  },
10
10
  "scripts": {
11
- "start": "node server.js",
12
- "dev": "node --watch server.js",
11
+ "start": "node --expose-gc server.js",
12
+ "dev": "node --expose-gc --watch server.js",
13
13
  "test": "jest",
14
14
  "test:watch": "jest --watch",
15
15
  "test:coverage": "jest --coverage",
@@ -48,7 +48,7 @@
48
48
  "express-rate-limit": "^7.4.1",
49
49
  "express-session": "^1.19.0",
50
50
  "fast-xml-parser": "^5.3.4",
51
- "fhir-validator-wrapper": "1.5.0",
51
+ "fhir-validator-wrapper": "1.7.0",
52
52
  "fhirpath": "^5.2.1",
53
53
  "fs-extra": "^11.3.3",
54
54
  "ini": "^6.0.0",
@@ -68,13 +68,15 @@
68
68
  "properties-file": "^3.6.4",
69
69
  "qrcode-generator": "^2.0.4",
70
70
  "re2js": "^2.8.0",
71
- "rimraf": "^5.0.10",
72
71
  "sqlite3": "^6.0.1",
73
72
  "tar": "^7.5.7",
74
73
  "winston": "^3.19.0",
75
74
  "winston-daily-rotate-file": "^4.7.1",
76
75
  "yaml": "^2.8.2"
77
76
  },
77
+ "optionalDependencies": {
78
+ "koffi": "^3.3.6"
79
+ },
78
80
  "devDependencies": {
79
81
  "@types/jest": "^30.0.0",
80
82
  "@typescript-eslint/eslint-plugin": "^8.54.0",
@@ -109,7 +111,7 @@
109
111
  "smart-health-card"
110
112
  ],
111
113
  "author": "Health Intersections Pty Ltd",
112
- "license": "BSD-3",
114
+ "license": "BSD-3-Clause",
113
115
  "overrides": {
114
116
  "@istanbuljs/load-nyc-config": {
115
117
  "js-yaml": "^4.1.0"
@@ -117,7 +119,8 @@
117
119
  },
118
120
  "bin": {
119
121
  "tx-import": "./tx/importers/tx-import.js",
120
- "fhirsmith": "./server.js"
122
+ "fhirsmith": "./server.js",
123
+ "fhirsmith-cluster": "./cluster.js"
121
124
  },
122
125
  "repository": {
123
126
  "type": "git",
@@ -0,0 +1,117 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ // HTML for /packages/feed-check: the form, and a run's report (see feed-check.js)
8
+
9
+ const escape = require('escape-html');
10
+
11
+ function form(values = {}) {
12
+ let html = '<form method="get" action="/packages/feed-check" class="mb-4">';
13
+ html += '<p><label for="url">Package feed URL</label> ';
14
+ html += `<input type="url" id="url" name="url" size="70" required maxlength="500" placeholder="https://example.org/fhir/package-feed.xml" value="${escape(values.url || '')}"> `;
15
+ html += '<button type="submit" class="btn btn-primary">Check the feed</button></p>';
16
+ html += '<p><label for="signing">Signing keys (optional)</label><br>';
17
+ html += '<textarea id="signing" name="signing" rows="6" cols="90" maxlength="5000" aria-describedby="signing-help" ' +
18
+ 'placeholder=\'{"required": false, "keys": [{"keyid": "SHA256:...", "key": "MFkw...", "expires": null}]}\'>' +
19
+ `${escape(values.signing || '')}</textarea><br>`;
20
+ html += '<small id="signing-help">The feed\'s <code>signing</code> block as JSON, as it would be in package-feeds.json (or the feed\'s whole entry). ' +
21
+ 'Signatures are checked against these keys instead of the ones the registry lists for the feed - so a feed can be tested before its keys are registered.</small></p>';
22
+ html += '<p><small>Reads the feed the way this registry\'s crawler does - each item, its package and its signature - ' +
23
+ 'and reports what it finds. Nothing is stored. Packages are downloaded, so a large feed takes a while; ' +
24
+ 'the check stops after a set number of items.</small></p>';
25
+ html += '</form>';
26
+ return html;
27
+ }
28
+
29
+ function renderForm(values) {
30
+ return form(values);
31
+ }
32
+
33
+ const SIGNATURE = {
34
+ confirmed: '<span class="text-success">confirmed</span>',
35
+ rejected: '<span class="text-danger">rejected</span>',
36
+ unfetchable: '<span class="text-danger">couldn\'t be fetched</span>',
37
+ unchecked: 'not checked',
38
+ absent: '<span class="text-muted">absent</span>'
39
+ };
40
+
41
+ function item(r) {
42
+ let html = '<tr>';
43
+ const status = r.status === 'ok' ? '<span class="text-success">ok</span>'
44
+ : r.status === 'error' ? '<span class="text-danger">error</span>' : '<span class="text-muted">skipped</span>';
45
+ html += `<td>${status}</td>`;
46
+ html += `<td>${r.link ? `<a href="${escape(r.link)}">${escape(r.title || '(no title)')}</a>` : escape(r.title || '(no title)')}</td>`;
47
+ html += `<td>${r.package ? escape(r.package.fhirVersions || '') : ''}</td>`;
48
+ html += `<td>${r.date ? escape(r.date.substring(0, 10)) : ''}</td>`;
49
+ html += `<td>${r.package ? r.package.sizeKB.toLocaleString() + ' KB' : ''}</td>`;
50
+ if (r.signature) {
51
+ const label = SIGNATURE[r.signature.state] || escape(r.signature.state);
52
+ html += `<td>${r.signature.url ? `<a href="${escape(r.signature.url)}">${label}</a>` : label}` +
53
+ (r.signature.detail && r.signature.state !== 'absent' ? `<br><small>${escape(r.signature.detail)}</small>` : '') + '</td>';
54
+ } else {
55
+ html += '<td></td>';
56
+ }
57
+ html += `<td>${escape(r.held || '')}</td>`;
58
+ const notes = [...r.problems.map(p => `<li class="text-danger">${escape(p)}</li>`),
59
+ ...r.warnings.map(w => `<li>${escape(w)}</li>`),
60
+ ...(r.skipped ? [`<li>${escape(r.skipped)}</li>`] : [])];
61
+ html += `<td>${notes.length ? `<ul class="mb-0">${notes.join('')}</ul>` : ''}</td>`;
62
+ html += '</tr>';
63
+ return html;
64
+ }
65
+
66
+ // job: {id, url, state (running, done, failed), report, error}
67
+ function renderJob(job) {
68
+ const r = job.report;
69
+ let html = `<p>Feed: <a href="${escape(job.url)}">${escape(job.url)}</a></p>`;
70
+ if (job.state === 'running') {
71
+ html += `<div class="alert alert-info" role="status">Checking... ${r ? r.items.length : 0} item(s) so far. ` +
72
+ `This page reloads every few seconds until the check finishes (<a href="/packages/feed-check/${escape(job.id)}">reload now</a>).</div>`;
73
+ } else if (job.state === 'failed') {
74
+ html += `<div class="alert alert-danger">The check failed: ${escape(job.error || 'unknown error')}</div>`;
75
+ }
76
+ if (r) {
77
+ if (r.registered) {
78
+ html += `<p>In the registry's feed list as <strong>${escape(r.registered.name || '(no name)')}</strong>.`;
79
+ html += r.signing && r.signingSource === 'registry' ? ` Signing keys: ${r.signing.keys.map(k => `<code>${escape(k)}</code>`).join(', ')}${r.signing.required ? ' (signatures required)' : ''}.`
80
+ : r.signingSource === 'given' ? '' : ' It lists no signing keys.';
81
+ html += '</p>';
82
+ }
83
+ if (r.signingSource === 'given') {
84
+ html += `<p>Signatures checked against the keys given with the check: ${r.signing.keys.map(k => `<code>${escape(k)}</code>`).join(', ')}${r.signing.required ? ' (signatures required)' : ''}.</p>`;
85
+ }
86
+ for (const n of r.notes) {
87
+ html += `<div class="alert alert-warning">${escape(n)}</div>`;
88
+ }
89
+ for (const e of r.errors) {
90
+ html += `<div class="alert alert-danger">${escape(e)}</div>`;
91
+ }
92
+ if (job.state !== 'running') {
93
+ const counts = { ok: 0, error: 0, skipped: 0 };
94
+ r.items.forEach(i => counts[i.status]++);
95
+ const sigs = {};
96
+ r.items.forEach(i => {
97
+ if (i.signature) {
98
+ sigs[i.signature.state] = (sigs[i.signature.state] || 0) + 1;
99
+ }
100
+ });
101
+ html += `<p><strong>${r.items.length}</strong> item(s) on ${r.pages.length} page(s): ${counts.ok} ok, ${counts.error} with errors, ${counts.skipped} skipped. ` +
102
+ `Signatures: ${Object.entries(sigs).map(([k, v]) => `${v} ${escape(k)}`).join(', ') || 'none'}. ` +
103
+ `${r.downloadedMB} MB downloaded.${r.truncated ? ' <strong>The check stopped at its item limit; the feed has more.</strong>' : ''}</p>`;
104
+ }
105
+ if (r.items.length > 0) {
106
+ html += '<div class="table-responsive"><table class="table table-striped">';
107
+ html += '<thead><tr><th>Status</th><th>Package</th><th>FHIR version</th><th>Published</th><th>Size</th><th>Signature</th><th>Held here</th><th>Notes</th></tr></thead><tbody>';
108
+ html += r.items.map(item).join('');
109
+ html += '</tbody></table></div>';
110
+ }
111
+ }
112
+ html += '<h3 class="mt-4">Check another feed</h3>';
113
+ html += form({});
114
+ return html;
115
+ }
116
+
117
+ module.exports = { renderForm, renderJob };