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,482 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// tx-stress.js - FHIR Terminology Server Stress Test Client
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// node tx-stress.js <server-url> [options]
|
|
7
|
+
//
|
|
8
|
+
// Examples:
|
|
9
|
+
// node tx-stress.js http://localhost:3000/tx
|
|
10
|
+
// node tx-stress.js http://localhost:3000/tx --threads 10 --duration 60
|
|
11
|
+
// node tx-stress.js http://localhost:3000/tx --threads 5 --duration 30 --mode search
|
|
12
|
+
// node tx-stress.js http://localhost:3000/tx --threads 20 --duration 120 --mode random --verbose
|
|
13
|
+
//
|
|
14
|
+
|
|
15
|
+
const SEARCH_TERMS = [
|
|
16
|
+
'body', 'status', 'gender', 'type', 'code', 'category', 'event',
|
|
17
|
+
'condition', 'procedure', 'medication', 'observation', 'allergy',
|
|
18
|
+
'diagnosis', 'encounter', 'clinical', 'admin', 'result', 'finding',
|
|
19
|
+
'disorder', 'substance', 'unit', 'country', 'language', 'ethnic',
|
|
20
|
+
'marital', 'contact', 'address', 'identifier', 'security', 'action',
|
|
21
|
+
'request', 'response', 'priority', 'severity', 'risk', 'method'
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// ── CLI parsing ──────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function parseArgs() {
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
|
|
29
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
30
|
+
console.log(`
|
|
31
|
+
FHIR Terminology Server Stress Test
|
|
32
|
+
|
|
33
|
+
Usage: node tx-stress.js <server-url> [options]
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--threads <n> Number of concurrent workers (default: 10)
|
|
37
|
+
--duration <s> Test duration in seconds (default: 60)
|
|
38
|
+
--mode <mode> ValueSet discovery mode (default: both)
|
|
39
|
+
browse - paginate through all ValueSets
|
|
40
|
+
search - search with random terms
|
|
41
|
+
both - alternate between modes
|
|
42
|
+
--pick <n> How many ValueSets to pick per cycle (default: 20)
|
|
43
|
+
--from <n> Pick from first N search results (default: 100)
|
|
44
|
+
--verbose Show per-request latencies
|
|
45
|
+
--delay <ms> Delay between requests per worker in ms (default: 0)
|
|
46
|
+
--help, -h Show this help
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
node tx-stress.js http://localhost:3000/tx
|
|
50
|
+
node tx-stress.js http://localhost:3000/tx --threads 20 --duration 120 --verbose
|
|
51
|
+
node tx-stress.js http://localhost:3000/tx --threads 5 --mode search --duration 30
|
|
52
|
+
`);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const config = {
|
|
57
|
+
serverUrl: args[0].replace(/\/+$/, ''),
|
|
58
|
+
threads: 10,
|
|
59
|
+
duration: 60,
|
|
60
|
+
mode: 'both',
|
|
61
|
+
pick: 20,
|
|
62
|
+
from: 100,
|
|
63
|
+
verbose: false,
|
|
64
|
+
delay: 0
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
for (let i = 1; i < args.length; i++) {
|
|
68
|
+
switch (args[i]) {
|
|
69
|
+
case '--threads': config.threads = parseInt(args[++i]); break;
|
|
70
|
+
case '--duration': config.duration = parseInt(args[++i]); break;
|
|
71
|
+
case '--mode': config.mode = args[++i]; break;
|
|
72
|
+
case '--pick': config.pick = parseInt(args[++i]); break;
|
|
73
|
+
case '--from': config.from = parseInt(args[++i]); break;
|
|
74
|
+
case '--verbose': config.verbose = true; break;
|
|
75
|
+
case '--delay': config.delay = parseInt(args[++i]); break;
|
|
76
|
+
default:
|
|
77
|
+
console.error(`Unknown option: ${args[i]}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!['browse', 'search', 'both'].includes(config.mode)) {
|
|
83
|
+
console.error(`Invalid mode: ${config.mode}. Use browse, search, or both.`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return config;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Stats collection ─────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
class StatsCollector {
|
|
93
|
+
constructor() {
|
|
94
|
+
this.requests = []; // { worker, phase, url, status, latencyMs, error, timestamp }
|
|
95
|
+
this.cycles = 0;
|
|
96
|
+
this.errors = 0;
|
|
97
|
+
this.startTime = null;
|
|
98
|
+
this.endTime = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
record(entry) {
|
|
102
|
+
this.requests.push(entry);
|
|
103
|
+
if (entry.error) this.errors++;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
completeCycle() {
|
|
107
|
+
this.cycles++;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
summarize() {
|
|
111
|
+
const duration = (this.endTime - this.startTime) / 1000;
|
|
112
|
+
const phases = {};
|
|
113
|
+
|
|
114
|
+
for (const req of this.requests) {
|
|
115
|
+
if (!phases[req.phase]) {
|
|
116
|
+
phases[req.phase] = { count: 0, errors: 0, latencies: [] };
|
|
117
|
+
}
|
|
118
|
+
const p = phases[req.phase];
|
|
119
|
+
p.count++;
|
|
120
|
+
if (req.error) p.errors++;
|
|
121
|
+
if (req.latencyMs !== null) p.latencies.push(req.latencyMs);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log('\n' + '═'.repeat(72));
|
|
125
|
+
console.log(' STRESS TEST RESULTS');
|
|
126
|
+
console.log('═'.repeat(72));
|
|
127
|
+
console.log(` Duration: ${duration.toFixed(1)}s`);
|
|
128
|
+
console.log(` Workers: ${this.workerCount}`);
|
|
129
|
+
console.log(` Complete cycles: ${this.cycles}`);
|
|
130
|
+
console.log(` Total requests: ${this.requests.length}`);
|
|
131
|
+
console.log(` Total errors: ${this.errors}`);
|
|
132
|
+
console.log(` Requests/sec: ${(this.requests.length / duration).toFixed(1)}`);
|
|
133
|
+
console.log(` Cycles/sec: ${(this.cycles / duration).toFixed(2)}`);
|
|
134
|
+
|
|
135
|
+
console.log('\n Per-phase breakdown:');
|
|
136
|
+
console.log(' ' + '-'.repeat(70));
|
|
137
|
+
console.log(` ${'Phase'.padEnd(16)} ${'Count'.padStart(7)} ${'Errors'.padStart(7)} ${'Avg ms'.padStart(8)} ${'p50 ms'.padStart(8)} ${'p95 ms'.padStart(8)} ${'p99 ms'.padStart(8)} ${'Max ms'.padStart(8)}`);
|
|
138
|
+
console.log(' ' + '-'.repeat(70));
|
|
139
|
+
|
|
140
|
+
for (const [phase, data] of Object.entries(phases)) {
|
|
141
|
+
const sorted = data.latencies.slice().sort((a, b) => a - b);
|
|
142
|
+
const avg = sorted.length ? (sorted.reduce((a, b) => a + b, 0) / sorted.length) : 0;
|
|
143
|
+
const p50 = percentile(sorted, 50);
|
|
144
|
+
const p95 = percentile(sorted, 95);
|
|
145
|
+
const p99 = percentile(sorted, 99);
|
|
146
|
+
const max = sorted.length ? sorted[sorted.length - 1] : 0;
|
|
147
|
+
|
|
148
|
+
console.log(` ${phase.padEnd(16)} ${String(data.count).padStart(7)} ${String(data.errors).padStart(7)} ${avg.toFixed(0).padStart(8)} ${p50.toFixed(0).padStart(8)} ${p95.toFixed(0).padStart(8)} ${p99.toFixed(0).padStart(8)} ${max.toFixed(0).padStart(8)}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Overall latency
|
|
152
|
+
const allLatencies = this.requests.filter(r => r.latencyMs !== null).map(r => r.latencyMs).sort((a, b) => a - b);
|
|
153
|
+
if (allLatencies.length > 0) {
|
|
154
|
+
const avg = allLatencies.reduce((a, b) => a + b, 0) / allLatencies.length;
|
|
155
|
+
console.log(' ' + '-'.repeat(70));
|
|
156
|
+
console.log(` ${'OVERALL'.padEnd(16)} ${String(this.requests.length).padStart(7)} ${String(this.errors).padStart(7)} ${avg.toFixed(0).padStart(8)} ${percentile(allLatencies, 50).toFixed(0).padStart(8)} ${percentile(allLatencies, 95).toFixed(0).padStart(8)} ${percentile(allLatencies, 99).toFixed(0).padStart(8)} ${allLatencies[allLatencies.length - 1].toFixed(0).padStart(8)}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log('═'.repeat(72));
|
|
160
|
+
|
|
161
|
+
// Error summary
|
|
162
|
+
const errorTypes = {};
|
|
163
|
+
for (const req of this.requests) {
|
|
164
|
+
if (req.error) {
|
|
165
|
+
const key = `${req.phase}: ${req.error}`;
|
|
166
|
+
errorTypes[key] = (errorTypes[key] || 0) + 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (Object.keys(errorTypes).length > 0) {
|
|
170
|
+
console.log('\n Error summary:');
|
|
171
|
+
for (const [msg, count] of Object.entries(errorTypes).sort((a, b) => b[1] - a[1])) {
|
|
172
|
+
console.log(` ${count}x ${msg}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function percentile(sorted, pct) {
|
|
179
|
+
if (sorted.length === 0) return 0;
|
|
180
|
+
const idx = Math.ceil((pct / 100) * sorted.length) - 1;
|
|
181
|
+
return sorted[Math.max(0, idx)];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── HTTP helpers ─────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
async function fhirGet(url, stats, worker, phase, verbose) {
|
|
187
|
+
const start = performance.now();
|
|
188
|
+
let status = null;
|
|
189
|
+
let error = null;
|
|
190
|
+
let body = null;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const resp = await fetch(url, {
|
|
194
|
+
headers: { 'Accept': 'application/fhir+json' },
|
|
195
|
+
signal: AbortSignal.timeout(30000)
|
|
196
|
+
});
|
|
197
|
+
status = resp.status;
|
|
198
|
+
|
|
199
|
+
if (!resp.ok) {
|
|
200
|
+
error = `HTTP ${resp.status}`;
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
body = await resp.json();
|
|
205
|
+
return body;
|
|
206
|
+
|
|
207
|
+
} catch (err) {
|
|
208
|
+
error = err.name === 'TimeoutError' ? 'Timeout (30s)' : err.message;
|
|
209
|
+
return null;
|
|
210
|
+
|
|
211
|
+
} finally {
|
|
212
|
+
const latencyMs = performance.now() - start;
|
|
213
|
+
const entry = {
|
|
214
|
+
worker,
|
|
215
|
+
phase,
|
|
216
|
+
url: shortenUrl(url),
|
|
217
|
+
status,
|
|
218
|
+
latencyMs,
|
|
219
|
+
error,
|
|
220
|
+
timestamp: new Date()
|
|
221
|
+
};
|
|
222
|
+
stats.record(entry);
|
|
223
|
+
|
|
224
|
+
if (verbose) {
|
|
225
|
+
const icon = error ? '✗' : '✓';
|
|
226
|
+
const statusStr = status ? `${status}` : '---';
|
|
227
|
+
console.log(` [W${String(worker).padStart(2)}] ${icon} ${phase.padEnd(14)} ${latencyMs.toFixed(0).padStart(6)}ms ${statusStr} ${shortenUrl(url)}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function shortenUrl(url) {
|
|
233
|
+
// Trim to last meaningful path segments for readable logging
|
|
234
|
+
try {
|
|
235
|
+
const u = new URL(url);
|
|
236
|
+
const path = u.pathname + u.search;
|
|
237
|
+
return path.length > 90 ? path.substring(0, 87) + '...' : path;
|
|
238
|
+
} catch {
|
|
239
|
+
return url.length > 90 ? url.substring(0, 87) + '...' : url;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── FHIR operations ──────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
async function searchValueSets(config, stats, worker, mode) {
|
|
246
|
+
const baseUrl = config.serverUrl;
|
|
247
|
+
const allEntries = [];
|
|
248
|
+
const pageSize = 50;
|
|
249
|
+
|
|
250
|
+
if (mode === 'search') {
|
|
251
|
+
// Search with a random term
|
|
252
|
+
const term = SEARCH_TERMS[Math.floor(Math.random() * SEARCH_TERMS.length)];
|
|
253
|
+
const url = `${baseUrl}/ValueSet?name:contains=${encodeURIComponent(term)}&_count=${pageSize}`;
|
|
254
|
+
const bundle = await fhirGet(url, stats, worker, 'vs-search', config.verbose);
|
|
255
|
+
if (bundle && bundle.entry) {
|
|
256
|
+
allEntries.push(...bundle.entry);
|
|
257
|
+
}
|
|
258
|
+
// Get a second page if we need more results and there is one
|
|
259
|
+
if (allEntries.length < config.from && bundle && bundle.link) {
|
|
260
|
+
const nextLink = bundle.link.find(l => l.relation === 'next');
|
|
261
|
+
if (nextLink) {
|
|
262
|
+
const bundle2 = await fhirGet(nextLink.url, stats, worker, 'vs-search', config.verbose);
|
|
263
|
+
if (bundle2 && bundle2.entry) {
|
|
264
|
+
allEntries.push(...bundle2.entry);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
// Browse mode - paginate through ValueSets
|
|
270
|
+
const offset = Math.floor(Math.random() * 200) * pageSize;
|
|
271
|
+
const url = `${baseUrl}/ValueSet?_count=${pageSize}&_offset=${offset}`;
|
|
272
|
+
const bundle = await fhirGet(url, stats, worker, 'vs-browse', config.verbose);
|
|
273
|
+
if (bundle && bundle.entry) {
|
|
274
|
+
allEntries.push(...bundle.entry);
|
|
275
|
+
}
|
|
276
|
+
// Get a second page
|
|
277
|
+
if (allEntries.length < config.from && bundle && bundle.link) {
|
|
278
|
+
const nextLink = bundle.link.find(l => l.relation === 'next');
|
|
279
|
+
if (nextLink) {
|
|
280
|
+
const bundle2 = await fhirGet(nextLink.url, stats, worker, 'vs-browse', config.verbose);
|
|
281
|
+
if (bundle2 && bundle2.entry) {
|
|
282
|
+
allEntries.push(...bundle2.entry);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return allEntries;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function pickRandom(arr, n) {
|
|
292
|
+
const copy = arr.slice(0, Math.min(arr.length, 200)); // cap working set
|
|
293
|
+
const result = [];
|
|
294
|
+
for (let i = 0; i < n && copy.length > 0; i++) {
|
|
295
|
+
const idx = Math.floor(Math.random() * copy.length);
|
|
296
|
+
result.push(copy.splice(idx, 1)[0]);
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function expandValueSet(config, stats, worker, vsUrl) {
|
|
302
|
+
const url = `${config.serverUrl}/ValueSet/$expand?url=${encodeURIComponent(vsUrl)}&count=20`;
|
|
303
|
+
const result = await fhirGet(url, stats, worker, 'vs-expand', config.verbose);
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function validateCode(config, stats, worker, system, code, vsUrl) {
|
|
308
|
+
const params = new URLSearchParams();
|
|
309
|
+
params.set('system', system);
|
|
310
|
+
params.set('code', code);
|
|
311
|
+
if (vsUrl) params.set('url', vsUrl);
|
|
312
|
+
|
|
313
|
+
const url = `${config.serverUrl}/ValueSet/$validate-code?${params.toString()}`;
|
|
314
|
+
const result = await fhirGet(url, stats, worker, 'validate-code', config.verbose);
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── Worker cycle ─────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
async function runWorkerCycle(config, stats, worker, deadline) {
|
|
321
|
+
// 1. Decide mode for this cycle
|
|
322
|
+
let mode;
|
|
323
|
+
if (config.mode === 'both') {
|
|
324
|
+
mode = Math.random() < 0.5 ? 'browse' : 'search';
|
|
325
|
+
} else {
|
|
326
|
+
mode = config.mode;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 2. Search for ValueSets
|
|
330
|
+
const entries = await searchValueSets(config, stats, worker, mode);
|
|
331
|
+
if (Date.now() >= deadline) return;
|
|
332
|
+
|
|
333
|
+
if (!entries || entries.length === 0) {
|
|
334
|
+
return; // nothing to work with this cycle
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 3. Pick a random subset
|
|
338
|
+
const picked = pickRandom(entries, config.pick);
|
|
339
|
+
|
|
340
|
+
// 4. Expand each and validate a code from each expansion
|
|
341
|
+
for (const entry of picked) {
|
|
342
|
+
if (Date.now() >= deadline) return;
|
|
343
|
+
|
|
344
|
+
const vs = entry.resource;
|
|
345
|
+
if (!vs || !vs.url) continue;
|
|
346
|
+
|
|
347
|
+
// Expand
|
|
348
|
+
const expansion = await expandValueSet(config, stats, worker, vs.url);
|
|
349
|
+
if (Date.now() >= deadline) return;
|
|
350
|
+
|
|
351
|
+
if (config.delay > 0) await sleep(config.delay);
|
|
352
|
+
|
|
353
|
+
// Find a code in the expansion to validate
|
|
354
|
+
const contains = expansion?.expansion?.contains;
|
|
355
|
+
if (contains && contains.length > 0) {
|
|
356
|
+
const pick = contains[Math.floor(Math.random() * contains.length)];
|
|
357
|
+
if (pick.system && pick.code) {
|
|
358
|
+
await validateCode(config, stats, worker, pick.system, pick.code, vs.url);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (config.delay > 0) await sleep(config.delay);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
stats.completeCycle();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function runWorker(config, stats, worker, deadline) {
|
|
369
|
+
while (Date.now() < deadline) {
|
|
370
|
+
try {
|
|
371
|
+
await runWorkerCycle(config, stats, worker, deadline);
|
|
372
|
+
} catch (err) {
|
|
373
|
+
// Shouldn't happen since fhirGet catches errors, but just in case
|
|
374
|
+
stats.record({
|
|
375
|
+
worker,
|
|
376
|
+
phase: 'cycle-error',
|
|
377
|
+
url: '',
|
|
378
|
+
status: null,
|
|
379
|
+
latencyMs: null,
|
|
380
|
+
error: err.message,
|
|
381
|
+
timestamp: new Date()
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function sleep(ms) {
|
|
388
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── Connectivity check ───────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
async function checkServer(config) {
|
|
394
|
+
console.log(`\nChecking server at ${config.serverUrl} ...`);
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
const resp = await fetch(`${config.serverUrl}/metadata`, {
|
|
398
|
+
headers: { 'Accept': 'application/fhir+json' },
|
|
399
|
+
signal: AbortSignal.timeout(10000)
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
if (!resp.ok) {
|
|
403
|
+
console.error(`Server returned HTTP ${resp.status}. Aborting.`);
|
|
404
|
+
process.exit(1);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const cs = await resp.json();
|
|
408
|
+
const sw = cs.software ? `${cs.software.name || '?'} ${cs.software.version || ''}` : 'unknown';
|
|
409
|
+
const fhirVer = cs.fhirVersion || '?';
|
|
410
|
+
console.log(` Server: ${sw} (FHIR ${fhirVer})`);
|
|
411
|
+
console.log(` Status: OK\n`);
|
|
412
|
+
return cs;
|
|
413
|
+
|
|
414
|
+
} catch (err) {
|
|
415
|
+
console.error(`Cannot reach server: ${err.message}`);
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── Live progress ────────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
function startProgressReporter(stats, config, deadline) {
|
|
423
|
+
const interval = setInterval(() => {
|
|
424
|
+
const elapsed = (Date.now() - stats.startTime) / 1000;
|
|
425
|
+
const remaining = Math.max(0, (deadline - Date.now()) / 1000);
|
|
426
|
+
const rps = stats.requests.length / elapsed;
|
|
427
|
+
process.stdout.write(
|
|
428
|
+
`\r ⏱ ${elapsed.toFixed(0)}s elapsed | ${remaining.toFixed(0)}s remaining | ` +
|
|
429
|
+
`${stats.requests.length} reqs (${rps.toFixed(1)}/s) | ` +
|
|
430
|
+
`${stats.cycles} cycles | ${stats.errors} errors `
|
|
431
|
+
);
|
|
432
|
+
}, 1000);
|
|
433
|
+
|
|
434
|
+
return () => {
|
|
435
|
+
clearInterval(interval);
|
|
436
|
+
process.stdout.write('\r' + ' '.repeat(100) + '\r');
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── Main ─────────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
async function main() {
|
|
443
|
+
const config = parseArgs();
|
|
444
|
+
|
|
445
|
+
console.log('╔' + '═'.repeat(70) + '╗');
|
|
446
|
+
console.log('║ FHIR Terminology Server Stress Test' + ' '.repeat(33) + '║');
|
|
447
|
+
console.log('╚' + '═'.repeat(70) + '╝');
|
|
448
|
+
console.log(` Server: ${config.serverUrl}`);
|
|
449
|
+
console.log(` Workers: ${config.threads}`);
|
|
450
|
+
console.log(` Duration: ${config.duration}s`);
|
|
451
|
+
console.log(` Mode: ${config.mode}`);
|
|
452
|
+
console.log(` Pick: ${config.pick} of first ${config.from}`);
|
|
453
|
+
if (config.delay > 0) console.log(` Delay: ${config.delay}ms between requests`);
|
|
454
|
+
|
|
455
|
+
await checkServer(config);
|
|
456
|
+
|
|
457
|
+
const stats = new StatsCollector();
|
|
458
|
+
stats.workerCount = config.threads;
|
|
459
|
+
stats.startTime = Date.now();
|
|
460
|
+
const deadline = stats.startTime + (config.duration * 1000);
|
|
461
|
+
|
|
462
|
+
console.log(`Starting ${config.threads} workers for ${config.duration}s...\n`);
|
|
463
|
+
|
|
464
|
+
const stopProgress = config.verbose ? () => {} : startProgressReporter(stats, config, deadline);
|
|
465
|
+
|
|
466
|
+
// Launch all workers concurrently
|
|
467
|
+
const workers = [];
|
|
468
|
+
for (let i = 1; i <= config.threads; i++) {
|
|
469
|
+
workers.push(runWorker(config, stats, i, deadline));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
await Promise.all(workers);
|
|
473
|
+
|
|
474
|
+
stats.endTime = Date.now();
|
|
475
|
+
stopProgress();
|
|
476
|
+
stats.summarize();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
main().catch(err => {
|
|
480
|
+
console.error(`Fatal error: ${err.message}`);
|
|
481
|
+
process.exit(1);
|
|
482
|
+
});
|
package/vcl/readme.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# VCL - ValueSet Compose Language
|
|
2
|
+
|
|
3
|
+
The `vcl` module turns an expression in VCL, the ValueSet Compose Language (a compact text
|
|
4
|
+
syntax for a value set's compose: includes, excludes, filters, ...), into a FHIR ValueSet.
|
|
5
|
+
It only parses: it doesn't expand the value set or check its codes. Use the `tx` module for
|
|
6
|
+
that.
|
|
7
|
+
|
|
8
|
+
This module exists for the FHIR tooling; it isn't
|
|
9
|
+
designed for anyone else to run, though nothing stops you.
|
|
10
|
+
|
|
11
|
+
## Configuration
|
|
12
|
+
|
|
13
|
+
In `config.json`, under `modules`:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
"vcl": {
|
|
17
|
+
"enabled": true
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
There are no other settings.
|
|
22
|
+
|
|
23
|
+
## Endpoint
|
|
24
|
+
|
|
25
|
+
`GET /VCL?vcl={expression}`
|
|
26
|
+
|
|
27
|
+
- The expression is the `vcl` query parameter (URL-encoded), up to 10000 characters. It may
|
|
28
|
+
also be given as a URI, `http://fhir.org/VCL/{expression}`.
|
|
29
|
+
- The response is the ValueSet, as JSON, with `compose` from the expression and `url`
|
|
30
|
+
`cid:{hash}` - a hash of the content, so the same expression always has the same url.
|
|
31
|
+
- An expression that isn't valid VCL is a 400, with `error`, `message` and, where it is known,
|
|
32
|
+
the `position` in the expression where parsing failed.
|
|
33
|
+
|
|
34
|
+
For example, `GET /VCL?vcl=(http://loinc.org)COMPONENT=LP15842-2` gives a ValueSet that includes
|
|
35
|
+
the LOINC codes whose COMPONENT is LP15842-2.
|
|
36
|
+
|
|
37
|
+
The parser (`vcl-parser.js`) is based on the Java VCL parser in the FHIR core library.
|
package/vcl/vcl-parser.js
CHANGED
|
@@ -309,10 +309,15 @@ class VCLLexer {
|
|
|
309
309
|
}
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
// How deep brackets may nest. The parser recurses once per bracket, so without a bound a hostile
|
|
313
|
+
// expression is a RangeError (stack overflow) rather than a parse error. Real VCL nests a few levels.
|
|
314
|
+
const MAX_VCL_DEPTH = 100;
|
|
315
|
+
|
|
312
316
|
class VCLParserClass {
|
|
313
317
|
constructor(tokens, fhirFactory = null) {
|
|
314
318
|
this.tokens = tokens;
|
|
315
319
|
this.pos = 0;
|
|
320
|
+
this.depth = 0;
|
|
316
321
|
this.fhirFactory = fhirFactory;
|
|
317
322
|
this.valueSet = this.createValueSet();
|
|
318
323
|
}
|
|
@@ -477,6 +482,9 @@ class VCLParserClass {
|
|
|
477
482
|
|
|
478
483
|
if (this.current().type === TokenType.OPEN) {
|
|
479
484
|
this.consume(TokenType.OPEN);
|
|
485
|
+
if (++this.depth > MAX_VCL_DEPTH) {
|
|
486
|
+
throw new VCLParseException(`The expression nests more than ${MAX_VCL_DEPTH} levels deep`, this.current().position);
|
|
487
|
+
}
|
|
480
488
|
|
|
481
489
|
// Check for nested system URI
|
|
482
490
|
if (this.current().type === TokenType.OPEN && this.peek().type === TokenType.URI) {
|
|
@@ -494,6 +502,7 @@ class VCLParserClass {
|
|
|
494
502
|
|
|
495
503
|
// This should fail for unmatched parentheses
|
|
496
504
|
this.consume(TokenType.CLOSE);
|
|
505
|
+
this.depth--;
|
|
497
506
|
} else {
|
|
498
507
|
this.parseSimpleExpr(systemUri, isExclusion);
|
|
499
508
|
}
|
|
@@ -834,6 +843,10 @@ class VCLParserClass {
|
|
|
834
843
|
const tokenType = this.current().type;
|
|
835
844
|
|
|
836
845
|
switch (tokenType) {
|
|
846
|
+
case TokenType.EOF:
|
|
847
|
+
// current() answers EOF forever once the tokens run out, so without this an
|
|
848
|
+
// unterminated "{" looped here until the terms array overflowed
|
|
849
|
+
throw new VCLParseException("Expected '}' to close the filter list", this.current().position);
|
|
837
850
|
case TokenType.LCRLY:
|
|
838
851
|
depth++;
|
|
839
852
|
break;
|
|
@@ -1035,6 +1048,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
|
|
1035
1048
|
splitSystemUri,
|
|
1036
1049
|
isVCLCompatible,
|
|
1037
1050
|
VCLParseException,
|
|
1051
|
+
MAX_VCL_DEPTH,
|
|
1038
1052
|
TokenType,
|
|
1039
1053
|
FilterOperator,
|
|
1040
1054
|
// Export classes for debugging
|
|
@@ -1053,6 +1067,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
|
|
1053
1067
|
splitSystemUri,
|
|
1054
1068
|
isVCLCompatible,
|
|
1055
1069
|
VCLParseException,
|
|
1070
|
+
MAX_VCL_DEPTH,
|
|
1056
1071
|
TokenType,
|
|
1057
1072
|
FilterOperator,
|
|
1058
1073
|
// Export classes for debugging
|
package/xig/readme.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# XIG - the Cross-IG resource browser
|
|
2
|
+
|
|
3
|
+
The `xig` module serves the FHIR Cross-IG index: every conformance resource (profiles, value
|
|
4
|
+
sets, code systems, extensions, ...) in every published implementation guide package, with a
|
|
5
|
+
page for each and a searchable list.
|
|
6
|
+
|
|
7
|
+
The content is a SQLite database built elsewhere, by the IG statistics process, and published
|
|
8
|
+
at `https://fhir.org/guides/stats/xig.db`. This module downloads it and serves it read-only. It
|
|
9
|
+
doesn't build or change it.
|
|
10
|
+
|
|
11
|
+
This module exists to run fhir.org's own service; it isn't designed for anyone else to run,
|
|
12
|
+
though nothing stops you.
|
|
13
|
+
|
|
14
|
+
## Configuration
|
|
15
|
+
|
|
16
|
+
In `config.json`, under `modules`:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
"xig": {
|
|
20
|
+
"enabled": true,
|
|
21
|
+
"autoUpdate": true,
|
|
22
|
+
"updateToken": "a-long-random-string"
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| Setting | Default | Description |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `enabled` | false | Run the module, at `/xig` |
|
|
29
|
+
| `autoUpdate` | true | Download the database again every night at 02:00 (server time). `false` keeps whatever is there |
|
|
30
|
+
| `updateToken` | none | A shared secret for `POST /xig/update`, which downloads the database now. Send it in the `x-update-token` header (or from the form on `/xig/stats`). Without it, that endpoint is turned off. The nightly update doesn't need it |
|
|
31
|
+
|
|
32
|
+
The database is kept at `xig/xig.db` in the data directory. If it isn't there at startup, it is
|
|
33
|
+
downloaded a few seconds after the server starts; until then the pages say there's no
|
|
34
|
+
database. A download replaces the database only once it is complete.
|
|
35
|
+
|
|
36
|
+
The database's HTML fragments are shown as they are, so the download URL is fixed to fhir.org
|
|
37
|
+
(over https) and isn't a setting.
|
|
38
|
+
|
|
39
|
+
## Endpoints
|
|
40
|
+
|
|
41
|
+
| Path | What |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `GET /xig` | The resource list, filtered by FHIR version (`ver`), authority (`auth`), realm (`realm`), resource type (`rt`), type (`type`), package (`pkg`), text (`text`) and `onlyUsed`; paged by `offset` |
|
|
44
|
+
| `GET /xig/resource/{package}/{type}/{id}` | One resource |
|
|
45
|
+
| `GET /xig/{package}/{type}/{id}` | The same, by a shorter path |
|
|
46
|
+
| `GET /xig/stats` | Statistics: the database, its updates, and requests |
|
|
47
|
+
| `GET /xig/status` | The database's status, as JSON |
|
|
48
|
+
| `GET /xig/cache` | Cache statistics, as JSON |
|
|
49
|
+
| `POST /xig/update` | Download the database now (needs `updateToken`) |
|
|
50
|
+
|
|
51
|
+
The list and resource pages can be asked for as JSON or CSV as well as HTML (`_fmt=json` or `_fmt=csv`, or an `Accept`
|
|
52
|
+
header).
|
package/xig/xig-template.html
CHANGED
|
@@ -53,8 +53,6 @@
|
|
|
53
53
|
<div id="stripe"> </div>
|
|
54
54
|
<div class="container"> <!-- container -->
|
|
55
55
|
<div style="background-color: #ad1f2f; padding: 6px; color: white;"> <!-- container -->
|
|
56
|
-
<a href="http://www.hl7.org/fhir" style="color: gold" title="Fast Healthcare Interoperability Resources - Home Page"><img border="0" src="/icon-fhir-16.png" style="vertical-align: text-bottom"/> <b>FHIR</b></a> © HL7.org |
|
|
57
|
-
<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] |
|
|
58
56
|
<a href="/" style="color: gold"> Server Home</a> |
|
|
59
57
|
<a href="/xig" style="color: gold">XIG Home</a> |
|
|
60
58
|
<a href="/xig/stats" style="color: gold">XIG Stats</a> |
|
|
@@ -70,7 +68,7 @@
|
|
|
70
68
|
<div class="inner-wrapper">
|
|
71
69
|
<div class="col-9">
|
|
72
70
|
|
|
73
|
-
<h2><img border="0" src="
|
|
71
|
+
<h2><a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" title="FHIRsmith [%ver%]"><img border="0" src="/FHIRsmith32.png" alt="FHIRsmith Logo" style="vertical-align: text-bottom"/></a> FHIR IG analytics</h2>
|
|
74
72
|
|
|
75
73
|
[%content%]
|
|
76
74
|
|
|
@@ -88,8 +86,7 @@
|
|
|
88
86
|
<div class="container"> <!-- container -->
|
|
89
87
|
<div class="inner-wrapper">
|
|
90
88
|
<p>
|
|
91
|
-
<a href="
|
|
92
|
-
<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] © HealthIntersections.com.au 2023+ |
|
|
89
|
+
<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" alt="FHIRsmith Logo" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] © HealthIntersections.com.au 2023+ |
|
|
93
90
|
XIG built as of [%download-date%] | [%total-resources%] resources in [%total-packages%] packages |
|
|
94
91
|
([%ms%] ms)
|
|
95
92
|
[%sponsorMessage%]
|
|
@@ -113,10 +110,10 @@
|
|
|
113
110
|
================================================== -->
|
|
114
111
|
<!-- Placed at the end of the document so the pages load faster -->
|
|
115
112
|
<!-- <script src="/assets/js/jquery.js"/> -->
|
|
116
|
-
<script src="/assets/js/bootstrap.min.js"
|
|
117
|
-
<script src="/assets/js/respond.min.js"
|
|
113
|
+
<script src="/assets/js/bootstrap.min.js"></script>
|
|
114
|
+
<script src="/assets/js/respond.min.js"></script>
|
|
118
115
|
|
|
119
|
-
<script src="/assets/js/fhir.js"
|
|
116
|
+
<script src="/assets/js/fhir.js"></script>
|
|
120
117
|
|
|
121
118
|
<!-- Analytics Below
|
|
122
119
|
================================================== -->
|