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.
- package/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +190 -9
- package/CONFIGURATION.md +305 -0
- package/LICENSE +29 -0
- package/README.md +29 -122
- package/cluster.js +1220 -0
- package/cluster.md +707 -0
- package/config-template.json +77 -8
- package/extension-tracker/extension-tracker-template.html +5 -8
- package/extension-tracker/extension-tracker.js +0 -2
- package/folder/folder.js +7 -6
- package/ips/analysis.js +560 -0
- package/ips/ips-template.html +5 -8
- package/ips/ips.js +331 -33
- package/ips/jurisdictions.js +65 -0
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +131 -17
- package/ips/presenter.js +1 -1
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +170 -20
- package/ips/render.js +769 -109
- package/ips/scripts.js +46 -0
- package/ips/store.js +123 -11
- package/ips/structure.js +247 -0
- package/ips/terminology.js +1 -1
- package/library/body-limit.js +22 -0
- package/library/cache-generation.js +15 -0
- package/library/cgroup-memory.js +133 -1
- package/library/cluster-counts.js +121 -0
- package/library/cluster-events.js +153 -0
- package/library/cluster-metrics.js +193 -0
- package/library/cluster-status.js +468 -0
- package/library/config-reader.js +96 -0
- package/library/daily-report.js +100 -2
- package/library/html-server.js +27 -1
- package/library/i18nsupport.js +23 -1
- package/library/keep-alive.js +18 -0
- package/library/languages.js +17 -3
- package/library/logger.js +31 -0
- package/library/memory-guard.js +428 -0
- package/library/native-memory.js +52 -0
- package/library/npm-audit.js +51 -25
- package/library/openapi-doc.js +7 -5
- package/library/package-manager.js +8 -0
- package/library/role.js +197 -0
- package/library/server-credentials.js +169 -0
- package/library/sqlite-utilities.js +59 -0
- package/library/ssrf-guard.js +100 -0
- package/library/static-response-stats.js +16 -0
- package/library/version-utilities.js +50 -0
- package/library/worker-count.js +192 -0
- package/nginx.md +30 -11
- package/npmprojector/indexer.js +18 -10
- package/package.json +11 -8
- package/packages/feed-check-page.js +117 -0
- package/packages/feed-check.js +279 -0
- package/packages/npm-check.js +57 -3
- package/packages/npm-signing.js +219 -0
- package/packages/openapi.yaml +207 -1
- package/packages/package-crawler.js +322 -41
- package/packages/packages-template.html +6 -8
- package/packages/packages.js +604 -57
- package/packages/readme.md +113 -1
- package/publisher/publisher-template.html +2 -5
- package/publisher/publisher.js +121 -110
- package/publisher/readme.md +75 -0
- package/registry/api.js +179 -1
- package/registry/crawler.js +86 -108
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/configuration.md +34 -0
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +58 -6
- package/root-bare-template.html +7 -4
- package/root-template.html +5 -8
- package/server.js +398 -198
- package/shl/shl.js +38 -37
- package/static/a11y/a11y-check.js +832 -0
- package/static/a11y/axe.min.js +12 -0
- package/static/assets/css/bootstrap-fhir.css +15 -15
- package/static/assets/images/flags/au.svg +18 -0
- package/static/assets/images/flags/unknown.svg +11 -0
- package/static/assets/images/flags/us.svg +63 -0
- package/static/assets/images/icon_audit.png +0 -0
- package/static/assets/images/icon_clinical.png +0 -0
- package/static/assets/images/icon_document.png +0 -0
- package/static/assets/images/icon_external.png +0 -0
- package/static/assets/images/icon_medication.png +0 -0
- package/static/assets/images/icon_organization.png +0 -0
- package/static/assets/images/icon_other.png +0 -0
- package/static/assets/images/icon_person.png +0 -0
- package/static/assets/images/icon_section.png +0 -0
- package/stats-db.js +45 -0
- package/stats.js +197 -10
- package/testing/testing-template.html +5 -8
- package/token/nginx_passport_setup.md +1 -1
- package/token/security_guide.md +1 -1
- package/token/token-template.html +2 -5
- package/token/token.js +40 -36
- package/translations/Messages.properties +19 -0
- package/tx/README.md +158 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +50 -16
- package/tx/cs/cs-api.js +284 -19
- package/tx/cs/cs-api.md +41 -4
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +17 -5
- package/tx/cs/cs-cpt.js +33 -16
- package/tx/cs/cs-cs.js +199 -24
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-icd11.js +21 -13
- package/tx/cs/cs-icf.js +22 -3
- package/tx/cs/cs-lang.js +2 -2
- package/tx/cs/cs-loinc.js +193 -67
- package/tx/cs/cs-mimetypes.js +12 -2
- package/tx/cs/cs-ndc.js +40 -25
- package/tx/cs/cs-omop.js +54 -29
- package/tx/cs/cs-rxnorm.js +300 -196
- package/tx/cs/cs-snomed.js +861 -95
- package/tx/cs/cs-ucum.js +14 -4
- package/tx/cs/cs-unii.js +18 -14
- package/tx/cs/cs-virtual.js +210 -0
- package/tx/ecosystem-resolver.js +413 -0
- package/tx/html/cluster-dash-metrics.liquid +26 -0
- package/tx/html/cluster-home-metrics.liquid +31 -0
- package/tx/html/cluster-metrics-script.liquid +251 -0
- package/tx/html/codesystem-operations.liquid +3 -3
- package/tx/html/ecl-panel.liquid +1 -1
- package/tx/html/external-metrics.liquid +17 -0
- package/tx/html/operations-form.liquid +17 -17
- package/tx/html/search-form.liquid +11 -11
- package/tx/html/status-line.liquid +58 -0
- package/tx/html/status-panel.liquid +140 -0
- package/tx/html/status-tasks.liquid +25 -0
- package/tx/html/tx-template.html +2 -5
- package/tx/importers/atc-to-fhir.js +17 -16
- package/tx/importers/export-icd11-codesystem.js +3 -2
- package/tx/importers/import-icd11.js +4 -3
- package/tx/importers/import-loinc.module.js +118 -56
- package/tx/importers/import-ndc.module.js +56 -44
- package/tx/importers/import-rxnorm.module.js +118 -79
- package/tx/importers/import-sct.module.js +125 -61
- package/tx/importers/import-unii.module.js +37 -36
- package/tx/importers/subset-loinc.module.js +49 -48
- package/tx/importers/subset-rxnorm.module.js +29 -28
- package/tx/importers/terminal.js +18 -0
- package/tx/importers/tx-import-base.js +13 -12
- package/tx/importers/tx-import-settings.js +5 -4
- package/tx/importers/tx-import.js +53 -52
- package/tx/library/canonical-resource.js +49 -0
- package/tx/library/codesystem.js +94 -5
- package/tx/library/designations.js +45 -2
- package/tx/library/extensions.js +13 -7
- package/tx/library/implicit-valuesets.js +66 -0
- package/tx/library/renderer.js +77 -21
- package/tx/library/sqlite-utilities.js +2 -0
- package/tx/library/tho-precedence.js +40 -0
- package/tx/library/ucum-parsers.js +11 -1
- package/tx/library/wait-for-cache.js +43 -0
- package/tx/library.js +321 -22
- package/tx/openapi-operations.js +6 -4
- package/tx/operation-context.js +460 -99
- package/tx/params.js +25 -0
- package/tx/problems.js +3 -5
- package/tx/provider.js +314 -67
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +81 -17
- package/tx/sct/editions.js +39 -0
- package/tx/sct/expressions.js +10 -1
- package/tx/sct/load-config.js +107 -0
- package/tx/sct/mmap.js +109 -0
- package/tx/sct/structures.js +175 -15
- package/tx/sct/text-index.js +5 -1
- package/tx/syndication/syndication-feed.js +2 -2
- package/tx/tx-html.js +44 -8
- package/tx/tx.fhir.org.yml +8 -8
- package/tx/tx.js +282 -80
- package/tx/usage-tracker.js +39 -11
- package/tx/vs/vs-api.js +19 -0
- package/tx/vs/vs-database.js +386 -62
- package/tx/vs/vs-package.js +44 -17
- package/tx/vs/vs-vsac.js +649 -101
- package/tx/workers/batch-validate.js +37 -10
- package/tx/workers/batch.js +6 -1
- package/tx/workers/cache-control.js +15 -4
- package/tx/workers/closure.js +156 -13
- package/tx/workers/compare.js +8 -5
- package/tx/workers/expand.js +1173 -289
- package/tx/workers/metadata.js +35 -0
- package/tx/workers/search.js +53 -1
- package/tx/workers/supplement-filters.js +145 -0
- package/tx/workers/translate.js +169 -4
- package/tx/workers/validate.js +223 -119
- package/tx/workers/worker.js +123 -31
- package/tx/xversion/xv-valueset.js +7 -4
- package/utilities/submit-test-report.js +3 -1
- package/utilities/tx-stress-test.js +482 -0
- package/vcl/readme.md +37 -0
- package/vcl/vcl-parser.js +15 -0
- package/xig/readme.md +52 -0
- package/xig/xig-template.html +5 -8
- package/xig/xig.js +238 -422
- package/changelog-template.md +0 -22
- package/registry/test-runner.js +0 -237
- package/release.sh +0 -80
- package/test-scripts/repro-re2-wasm-leak.js +0 -34
- package/tsconfig.eslint.json +0 -9
- package/tx/html/dash-metrics.liquid +0 -147
- 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.
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
|
package/npmprojector/indexer.js
CHANGED
|
@@ -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
|
-
|
|
89
|
-
//
|
|
90
|
-
|
|
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.
|
|
4
|
-
"txVersion": "1.
|
|
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.
|
|
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 };
|