fhirsmith 0.17.0 → 0.18.1

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 (131) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +432 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +8 -3
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +10 -0
  89. package/tx/README.md +52 -0
  90. package/tx/closure/closure-store.js +14 -0
  91. package/tx/cs/cs-api.js +92 -14
  92. package/tx/cs/cs-cs.js +64 -3
  93. package/tx/cs/cs-icd11.js +366 -112
  94. package/tx/cs/cs-icf.js +431 -0
  95. package/tx/cs/cs-loinc.js +296 -27
  96. package/tx/cs/cs-omop.js +10 -3
  97. package/tx/cs/cs-snomed.js +1210 -80
  98. package/tx/cs/cs-ucum.js +6 -0
  99. package/tx/cs/icd11-expressions.js +191 -78
  100. package/tx/importers/csv-records.js +35 -0
  101. package/tx/importers/export-icd11-codesystem.js +58 -12
  102. package/tx/importers/icd11-schema.md +9 -5
  103. package/tx/importers/import-loinc.module.js +177 -41
  104. package/tx/importers/import-sct.module.js +66 -1
  105. package/tx/importers/subset-loinc.module.js +25 -6
  106. package/tx/library/codesystem.js +4 -1
  107. package/tx/library/designations.js +23 -30
  108. package/tx/library.js +98 -4
  109. package/tx/openapi-operations.js +6 -1
  110. package/tx/params.js +8 -1
  111. package/tx/provider.js +24 -6
  112. package/tx/sct/documentation.md +251 -0
  113. package/tx/sct/ecl.js +758 -46
  114. package/tx/sct/expressions.js +113 -52
  115. package/tx/sct/structures.js +8 -2
  116. package/tx/syndication/syndication-feed.js +218 -0
  117. package/tx/syndication/syndication-store.js +415 -0
  118. package/tx/tx-html.js +15 -1
  119. package/tx/tx.fhir.org.yml +3 -4
  120. package/tx/tx.js +144 -2
  121. package/tx/vs/vs-vsac.js +25 -6
  122. package/tx/workers/closure.js +20 -0
  123. package/tx/workers/expand.js +35 -8
  124. package/tx/workers/lookup.js +61 -6
  125. package/tx/workers/subsumes.js +1 -1
  126. package/tx/workers/translate.js +166 -15
  127. package/tx/workers/validate.js +138 -6
  128. package/utilities/make-sct-loinc-subset.js +250 -0
  129. package/utilities/make-sct-test-extension.js +264 -0
  130. package/xig/xig.js +32 -0
  131. package/registry/readme.md +0 -124
@@ -0,0 +1,395 @@
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
+ /**
8
+ * The terminology view of an IPS document: every CodeableConcept in it, in a fixed order
9
+ * - the Composition's own, then section by section in the IPS order (whatever order the
10
+ * sections come in), then any other sections in document order - and, within a section,
11
+ * entry by entry, element by element.
12
+ *
13
+ * CodeableConcepts are found by walking each resource with the R4 type model fhirpath.js
14
+ * ships (path -> type), so that an element is reported as a CodeableConcept because the
15
+ * specification says it is one, not because it happens to look like one; for a resource
16
+ * or element the model doesn't know, an object with a `coding` array is taken to be one.
17
+ * Section codes are not reported (the section itself says what they are), nor are the
18
+ * elements in IGNORED below.
19
+ *
20
+ * @module ips/terminology
21
+ */
22
+
23
+ const r4Model = require('fhirpath/fhir-context/r4');
24
+ const { compositionOf, resolveInBundle, bundleIndex } = require('./search');
25
+ const { editionCode, editionName } = require('../tx/sct/editions');
26
+
27
+ // the IPS sections, in the order the view presents them: [LOINC code, title, words that
28
+ // identify the section by its title when the code is missing or different]
29
+ const SECTION_ORDER = [
30
+ ['81338-6', 'Patient Story', /patient story/i],
31
+ ['11450-4', 'Problems', /^(active )?problem/i],
32
+ ['48765-2', 'Allergies and Intolerances', /allerg/i],
33
+ ['10160-0', 'Medication Summary', /medication/i],
34
+ ['11369-6', 'Immunizations', /immuni[sz]ation|vaccin/i],
35
+ ['47519-4', 'History of Procedures', /procedure/i],
36
+ ['11348-0', 'History of Past Problems', /past (problem|illness)|history of past/i],
37
+ ['46264-8', 'Medical Devices', /device/i],
38
+ ['42348-3', 'Advance Directives', /advance directive/i],
39
+ ['104605-1', 'Alerts', /^alert/i],
40
+ ['47420-5', 'Functional Status', /functional status/i],
41
+ ['10162-6', 'Pregnancy History', /pregnan/i],
42
+ ['29762-2', 'Social History', /social history/i],
43
+ ['18776-5', 'Plan of Care', /plan of (care|treatment)|care plan/i],
44
+ ['8716-3', 'Vital Signs', /vital sign/i],
45
+ ['30954-2', 'Results', /^results?$|diagnostic results|relevant diagnostic/i]
46
+ ];
47
+
48
+ // elements whose CodeableConcepts are not interesting: resource type + path within it
49
+ // (without indices), or *.name for an element of that name anywhere
50
+ const IGNORED = new Set([
51
+ '*.category',
52
+ 'Composition.type',
53
+ 'Composition.event.code',
54
+ 'AllergyIntolerance.clinicalStatus',
55
+ 'AllergyIntolerance.verificationStatus',
56
+ 'Condition.clinicalStatus',
57
+ 'Condition.verificationStatus'
58
+ ]);
59
+
60
+ /** Is this element (an index-free path) on the ignore list? */
61
+ function isIgnored(element) {
62
+ return IGNORED.has(element) || IGNORED.has('*' + element.substring(element.lastIndexOf('.')));
63
+ }
64
+
65
+ // the external code systems - the big terminologies that no FHIR server holds as a
66
+ // CodeSystem resource, so there is nothing to link to - with their names for the display.
67
+ // Anything else under terminology.hl7.org or hl7.org/fhir is named by its last path segment
68
+ const SYSTEM_NAMES = {
69
+ 'http://snomed.info/sct': 'SNOMED CT',
70
+ 'http://loinc.org': 'LOINC',
71
+ 'http://www.nlm.nih.gov/research/umls/rxnorm': 'RxNorm',
72
+ 'http://unitsofmeasure.org': 'UCUM',
73
+ 'http://www.whocc.no/atc': 'ATC',
74
+ 'http://hl7.org/fhir/sid/icd-10': 'ICD-10',
75
+ 'http://hl7.org/fhir/sid/icd-10-cm': 'ICD-10-CM',
76
+ 'http://hl7.org/fhir/sid/icd-9-cm': 'ICD-9-CM',
77
+ 'http://hl7.org/fhir/sid/icd-9': 'ICD-9',
78
+ 'http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl': 'NCI Thesaurus',
79
+ 'http://id.who.int/icd/release/11/mms': 'ICD-11 MMS',
80
+ 'http://id.who.int/icd11/mms': 'ICD-11 MMS',
81
+ 'http://hl7.org/fhir/sid/cvx': 'CVX',
82
+ 'http://hl7.org/fhir/sid/ndc': 'NDC',
83
+ 'http://www.ama-assn.org/go/cpt': 'CPT',
84
+ 'http://fdasis.nlm.nih.gov': 'UNII',
85
+ 'http://www.radlex.org': 'RadLex',
86
+ 'http://dicom.nema.org/resources/ontology/DCM': 'DICOM',
87
+ 'urn:ietf:bcp:47': 'BCP 47 (language)',
88
+ 'urn:iso:std:iso:3166': 'ISO 3166 (country)',
89
+ 'urn:ietf:rfc:3986': 'URI',
90
+ 'http://unstats.un.org/unsd/methods/m49/m49.htm': 'UN M49',
91
+ 'http://standardterms.edqm.eu': 'EDQM',
92
+ 'https://www.gs1.org/gtin': 'GTIN',
93
+ 'http://www.nlm.nih.gov/research/umls': 'UMLS',
94
+ 'http://varnomen.hgvs.org': 'HGVS',
95
+ 'http://www.genenames.org': 'HGNC',
96
+ 'http://sequenceontology.org': 'Sequence Ontology',
97
+ 'http://purl.obolibrary.org/obo/hp.owl': 'HPO',
98
+ 'http://www.orpha.net': 'Orphanet'
99
+ };
100
+
101
+ /** Is this one of the external code systems (SNOMED CT, LOINC, ...)? */
102
+ function isExternalSystem(url) {
103
+ return typeof url === 'string' && Object.prototype.hasOwnProperty.call(SYSTEM_NAMES, url);
104
+ }
105
+
106
+ /**
107
+ * A readable name for a code system URL - DataRenderer.displaySystem, less the lookup of
108
+ * a CodeSystem resource's title (the tx server might know one, but that's a query away).
109
+ */
110
+ function systemName(url) {
111
+ if (typeof url !== 'string' || url === '') {
112
+ return '';
113
+ }
114
+ if (url.startsWith('http://snomed.info')) {
115
+ return 'SNOMED CT';
116
+ }
117
+ if (SYSTEM_NAMES[url]) {
118
+ return SYSTEM_NAMES[url];
119
+ }
120
+ const m = /^https?:\/\/(?:terminology\.hl7\.org\/CodeSystem|hl7\.org\/fhir(?:\/[a-z-]+)*)\/([^/]+)$/.exec(url);
121
+ if (m) {
122
+ return m[1];
123
+ }
124
+ return url;
125
+ }
126
+
127
+ const SCT_MONTHS = { '01': 'Jan', '02': 'Feb', '03': 'Mar', '04': 'Apr', '05': 'May', '06': 'Jun', '07': 'Jul', '08': 'Aug', '09': 'Sep', '10': 'Oct', '11': 'Nov', '12': 'Dec' };
128
+
129
+ /**
130
+ * The edition module id in a SNOMED CT version URI (http://snomed.info/sct/[module] or
131
+ * .../version/[date]), or a bare module id - SnomedUtilities.getEditionFromVersion, but
132
+ * returning the id whether or not it is a known edition.
133
+ */
134
+ function sctEdition(version) {
135
+ if (typeof version !== 'string') {
136
+ return null;
137
+ }
138
+ let v = version.startsWith('http://snomed.info/sct/') ? version.substring(23) : version;
139
+ if (v.includes('/')) {
140
+ v = v.substring(0, v.indexOf('/'));
141
+ }
142
+ return /^\d{6,18}$/.test(v) ? v : null;
143
+ }
144
+
145
+ /**
146
+ * A short description of a code system version - DataRenderer.describeVersion. A SNOMED CT
147
+ * version URI becomes the edition's short code (from tx/sct/editions.js) and release:
148
+ * "US rel. Mar 2024"; anything else is returned as it is.
149
+ *
150
+ * @returns {{text: string, title: string|null}} title is the edition's full name, for a hover
151
+ */
152
+ function describeVersion(version) {
153
+ if (typeof version !== 'string' || version === '') {
154
+ return { text: '', title: null };
155
+ }
156
+ if (!version.startsWith('http://snomed.info/sct')) {
157
+ return { text: version, title: null };
158
+ }
159
+ const p = version.split('/');
160
+ let ed;
161
+ let dt = '';
162
+ if (p.length >= 3 && p[p.length - 2] === 'version') {
163
+ ed = p[p.length - 3];
164
+ const date = p[p.length - 1];
165
+ if (/^\d{8}$/.test(date)) {
166
+ dt = ` rel. ${SCT_MONTHS[date.substring(4, 6)] || date.substring(4, 6)} ${date.substring(0, 4)}`;
167
+ }
168
+ } else {
169
+ ed = p[p.length - 1];
170
+ }
171
+ const code = editionCode(ed);
172
+ return { text: (code || '??') + dt, title: editionName(ed) || (ed ? `Unknown edition ${ed}` : null) };
173
+ }
174
+
175
+ /**
176
+ * Where to read about a code system - DataRenderer.getLinkForSystem for the external
177
+ * ones; null for anything else (the terminology server's page is the caller's business).
178
+ */
179
+ function linkForSystem(system) {
180
+ switch (system) {
181
+ case 'http://snomed.info/sct': return 'https://browser.ihtsdotools.org/';
182
+ case 'http://loinc.org': return 'https://loinc.org/';
183
+ case 'http://unitsofmeasure.org': return 'http://ucum.org';
184
+ case 'http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl': return 'https://ncit.nci.nih.gov/ncitbrowser/pages/home.jsf';
185
+ default: return null;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Where to read about a code - DataRenderer.getLinkForCode for the external code systems
191
+ * (SNOMED CT by SnomedUtilities.getSctLink: the edition's URI when the version names one,
192
+ * else http://snomed.info/id/[code]; a post-coordinated expression goes to the browser);
193
+ * null for anything else.
194
+ */
195
+ function linkForCode(system, version, code) {
196
+ const has = typeof code === 'string' && code !== '';
197
+ switch (system) {
198
+ case 'http://snomed.info/sct': {
199
+ if (!has || code.includes(':')) {
200
+ return 'https://browser.ihtsdotools.org/';
201
+ }
202
+ const ed = sctEdition(version);
203
+ return ed && editionName(ed) ? `http://snomed.info/sct/${ed}/id/${encodeURIComponent(code)}` : `http://snomed.info/id/${encodeURIComponent(code)}`;
204
+ }
205
+ case 'http://loinc.org':
206
+ return has ? `https://loinc.org/${encodeURIComponent(code)}` : 'https://loinc.org/';
207
+ case 'http://www.nlm.nih.gov/research/umls/rxnorm':
208
+ return has ? `https://mor.nlm.nih.gov/RxNav/search?searchBy=RXCUI&searchTerm=${encodeURIComponent(code)}` : 'https://www.nlm.nih.gov/research/umls/rxnorm/index.html';
209
+ case 'http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl':
210
+ return has ? `https://ncit.nci.nih.gov/ncitbrowser/ConceptReport.jsp?code=${encodeURIComponent(code)}` : 'https://ncit.nci.nih.gov/ncitbrowser/pages/home.jsf';
211
+ case 'urn:iso:std:iso:3166':
212
+ return has ? `https://en.wikipedia.org/wiki/ISO_3166-2:${encodeURIComponent(code)}` : 'https://en.wikipedia.org/wiki/ISO_3166-2';
213
+ default:
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function isObject(v) {
219
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
220
+ }
221
+
222
+ /** Does this look like a CodeableConcept, for when the model has nothing to say? */
223
+ function looksLikeCodeableConcept(v) {
224
+ return isObject(v) && Array.isArray(v.coding) && v.coding.every(c => isObject(c)) &&
225
+ Object.keys(v).every(k => ['coding', 'text', 'id', 'extension'].includes(k));
226
+ }
227
+
228
+ /**
229
+ * The type of an element, from the model. `modelPath` is the path the model knows
230
+ * (Type.element.element, with datatypes starting their own path); null if the model
231
+ * doesn't know it.
232
+ */
233
+ function typeOf(modelPath) {
234
+ let p = modelPath;
235
+ for (let i = 0; i < 5 && p; i++) {
236
+ const t = r4Model.path2Type[p];
237
+ if (t) {
238
+ return { type: t, path: p };
239
+ }
240
+ // e.g. Composition.section.section is defined at Composition.section
241
+ p = r4Model.pathsDefinedElsewhere[p];
242
+ }
243
+ return null;
244
+ }
245
+
246
+ /**
247
+ * Walk a resource (or datatype) collecting its CodeableConcepts.
248
+ *
249
+ * @param {Object} node - the resource or element
250
+ * @param {string} modelPath - the model's path for node (e.g. 'Observation.component')
251
+ * @param {string} shownPath - the path shown to people (e.g. 'Observation.component[1]')
252
+ * @param {string} resourceType - for the IGNORED check
253
+ * @param {Array} out
254
+ * @param {Set} skip - element names not to descend into, at this level
255
+ */
256
+ function walk(node, modelPath, shownPath, resourceType, out, skip = null) {
257
+ if (!isObject(node)) {
258
+ return;
259
+ }
260
+ for (const [key, value] of Object.entries(node)) {
261
+ if (key === 'resourceType' || (skip && skip.has(key)) || value === null || typeof value !== 'object') {
262
+ continue;
263
+ }
264
+ const resolved = typeOf(`${modelPath}.${key}`);
265
+ const type = resolved ? resolved.type : null;
266
+ const items = Array.isArray(value) ? value : [value];
267
+ items.forEach((item, i) => {
268
+ if (!isObject(item)) {
269
+ return;
270
+ }
271
+ const shown = `${shownPath}.${key}${Array.isArray(value) ? `[${i}]` : ''}`;
272
+ // the ignore list is by element path, without indices
273
+ const plain = shown.replace(/\[\d+\]/g, '');
274
+ if (type === 'CodeableConcept' || (type === null && looksLikeCodeableConcept(item))) {
275
+ if (!isIgnored(plain)) {
276
+ out.push({ path: shown, element: plain, concept: item });
277
+ }
278
+ return;
279
+ }
280
+ if (type === null) {
281
+ // unknown to the model: keep looking inside, in case
282
+ walk(item, `${modelPath}.${key}`, shown, resourceType, out);
283
+ } else if (type === 'BackboneElement' || type === 'Element') {
284
+ walk(item, resolved.path, shown, resourceType, out);
285
+ } else if (/^[A-Z]/.test(type) && type !== 'Resource' && type !== 'Reference' && type !== 'Narrative') {
286
+ // a datatype: its own elements are defined on the type
287
+ walk(item, type, shown, resourceType, out);
288
+ }
289
+ // primitives, references and narratives have no CodeableConcepts in them
290
+ });
291
+ }
292
+ }
293
+
294
+ /**
295
+ * The CodeableConcepts in one resource.
296
+ * @param {Object} resource
297
+ * @param {{skip?: string[]}} [options] - top-level elements to leave out
298
+ * @returns {Array<{path: string, element: string, concept: Object}>}
299
+ */
300
+ function conceptsOf(resource, options = {}) {
301
+ const out = [];
302
+ if (isObject(resource) && typeof resource.resourceType === 'string') {
303
+ walk(resource, resource.resourceType, resource.resourceType, resource.resourceType, out, options.skip ? new Set(options.skip) : null);
304
+ }
305
+ return out;
306
+ }
307
+
308
+ /** Which IPS section this is, if any: its index in SECTION_ORDER, or -1. */
309
+ function sectionRank(section) {
310
+ const codes = Array.isArray(section?.code?.coding) ? section.code.coding : [];
311
+ for (const c of codes) {
312
+ if (c?.system === 'http://loinc.org' || !c?.system) {
313
+ const i = SECTION_ORDER.findIndex(s => s[0] === c?.code);
314
+ if (i >= 0) {
315
+ return i;
316
+ }
317
+ }
318
+ }
319
+ const title = typeof section?.title === 'string' ? section.title.trim() : '';
320
+ if (title) {
321
+ const i = SECTION_ORDER.findIndex(s => s[2].test(title));
322
+ if (i >= 0) {
323
+ return i;
324
+ }
325
+ }
326
+ return -1;
327
+ }
328
+
329
+ /** A label for a resource: Type/id, or the entry's fullUrl, or just the type. */
330
+ function resourceLabel(resource, fullUrl) {
331
+ if (!isObject(resource)) {
332
+ return '';
333
+ }
334
+ if (typeof resource.id === 'string') {
335
+ return `${resource.resourceType}/${resource.id}`;
336
+ }
337
+ return fullUrl ? `${resource.resourceType} (${fullUrl})` : resource.resourceType;
338
+ }
339
+
340
+ /**
341
+ * The terminology of a document.
342
+ *
343
+ * @param {Object} bundle
344
+ * @returns {{composition: Array, sections: Array<{title: string, known: boolean, entries: Array<{
345
+ * reference: string, label: string, resolved: boolean, concepts: Array}>}>}}
346
+ */
347
+ function terminologyOf(bundle) {
348
+ const comp = compositionOf(bundle);
349
+ if (!comp) {
350
+ return { composition: [], sections: [] };
351
+ }
352
+ const result = {
353
+ composition: conceptsOf(comp, { skip: ['section'] }),
354
+ sections: []
355
+ };
356
+ // sections, flattened (a nested section is its own entry in the list, after its parent)
357
+ const flat = [];
358
+ const collect = (secs, depth) => {
359
+ for (const s of Array.isArray(secs) ? secs : []) {
360
+ if (isObject(s)) {
361
+ flat.push({ section: s, depth, order: flat.length });
362
+ collect(s.section, depth + 1);
363
+ }
364
+ }
365
+ };
366
+ collect(comp.section, 0);
367
+ const ranked = flat.map(f => ({ ...f, rank: sectionRank(f.section) }));
368
+ ranked.sort((a, b) => {
369
+ const ra = a.rank < 0 ? Number.MAX_SAFE_INTEGER : a.rank;
370
+ const rb = b.rank < 0 ? Number.MAX_SAFE_INTEGER : b.rank;
371
+ return ra - rb || a.order - b.order;
372
+ });
373
+ const fullUrls = bundleIndex(bundle).fullUrlOf;
374
+ const fullUrlOf = (resource) => fullUrls.get(resource) || null;
375
+ for (const { section, rank } of ranked) {
376
+ const title = rank >= 0 ? SECTION_ORDER[rank][1] : (typeof section.title === 'string' && section.title ? section.title : '(untitled section)');
377
+ const entries = [];
378
+ for (const ref of Array.isArray(section.entry) ? section.entry : []) {
379
+ const reference = typeof ref?.reference === 'string' ? ref.reference : '';
380
+ const resource = resolveInBundle(bundle, reference);
381
+ if (!resource) {
382
+ entries.push({ reference, label: reference || '(no reference)', resolved: false, concepts: [] });
383
+ continue;
384
+ }
385
+ entries.push({ reference, label: resourceLabel(resource, fullUrlOf(resource)), resolved: true, concepts: conceptsOf(resource) });
386
+ }
387
+ result.sections.push({ title, known: rank >= 0, code: section.code, entries });
388
+ }
389
+ return result;
390
+ }
391
+
392
+ module.exports = {
393
+ terminologyOf, conceptsOf, systemName, describeVersion, sctEdition, linkForSystem, linkForCode, isExternalSystem, isIgnored,
394
+ sectionRank, SECTION_ORDER, IGNORED, SYSTEM_NAMES
395
+ };
@@ -0,0 +1,202 @@
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
+ /**
8
+ * The terminology server the /ips module uses: for links to code systems now, and for
9
+ * the queries the analysis will make.
10
+ *
11
+ * It is named by modules.ips.txServer, which is either
12
+ *
13
+ * - an http(s) URL: an external server, reached over the network; or
14
+ * - a path on this server, such as /tx/r4: the tx module's own endpoint. Requests to it
15
+ * never leave the process - they are dispatched straight into the Express app, so
16
+ * they go through the same middleware and routes an HTTP request would, without a
17
+ * socket.
18
+ *
19
+ * Either way, the client speaks FHIR over the server's REST interface, so the analysis
20
+ * code doesn't care which it has.
21
+ *
22
+ * @module ips/tx-client
23
+ */
24
+
25
+ const http = require('http');
26
+ const { Duplex } = require('stream');
27
+
28
+ const FHIR_JSON = 'application/fhir+json';
29
+
30
+ /**
31
+ * Send a request into an Express app without a network: a real http.IncomingMessage and
32
+ * http.ServerResponse, with no socket, so everything Express and its middleware rely on
33
+ * (headers, the body stream, res.end) is there.
34
+ *
35
+ * @param {Function} app - the Express app
36
+ * @param {{method: string, url: string, headers?: Object, body?: string}} req
37
+ * @returns {Promise<{status: number, headers: Object, body: string}>}
38
+ */
39
+ function dispatch(app, { method, url, headers = {}, body }) {
40
+ return new Promise((resolve, reject) => {
41
+ const req = new http.IncomingMessage(null);
42
+ req.method = method;
43
+ req.url = url;
44
+ req.headers = Object.fromEntries(Object.entries({ host: 'localhost', ...headers }).map(([k, v]) => [k.toLowerCase(), String(v)]));
45
+ // enough of a socket for Express, on-finished, the body parsers and the message's own
46
+ // teardown to be happy: a stream that goes nowhere
47
+ req.socket = new Duplex({ read() {}, write(chunk, encoding, cb) { cb(); } });
48
+ req.socket.remoteAddress = '127.0.0.1';
49
+ req.socket.encrypted = false;
50
+ req.connection = req.socket;
51
+ if (body !== undefined) {
52
+ const buf = Buffer.from(body, 'utf8');
53
+ req.headers['content-length'] = String(buf.length);
54
+ // the body is the stream Express's body parsers read
55
+ req.push(buf);
56
+ }
57
+ // a real request is marked complete by the HTTP parser once its body is in; the body
58
+ // parsers (on-finished) look at that to know a later parser needn't read again
59
+ req.on('end', () => {
60
+ req.complete = true;
61
+ });
62
+ req.push(null);
63
+ const res = new http.ServerResponse(req);
64
+ const chunks = [];
65
+ res.write = (chunk, encoding, cb) => {
66
+ if (chunk) {
67
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8'));
68
+ }
69
+ if (typeof encoding === 'function') {
70
+ encoding();
71
+ } else if (cb) {
72
+ cb();
73
+ }
74
+ return true;
75
+ };
76
+ res.end = (chunk, encoding, cb) => {
77
+ if (typeof chunk === 'function') {
78
+ cb = chunk;
79
+ chunk = undefined;
80
+ } else if (typeof encoding === 'function') {
81
+ cb = encoding;
82
+ encoding = undefined;
83
+ }
84
+ if (chunk) {
85
+ res.write(chunk, encoding);
86
+ }
87
+ res.finished = true;
88
+ res.writableEnded = true;
89
+ if (cb) {
90
+ cb();
91
+ }
92
+ resolve({ status: res.statusCode, headers: res.getHeaders(), body: Buffer.concat(chunks).toString('utf8') });
93
+ return res;
94
+ };
95
+ try {
96
+ app(req, res);
97
+ } catch (e) {
98
+ reject(e);
99
+ }
100
+ });
101
+ }
102
+
103
+ class TxClient {
104
+ /**
105
+ * @param {string} server - modules.ips.txServer: an http(s) URL, or a path on this server
106
+ * @param {Object} [options]
107
+ * @param {Function} [options.app] - the Express app, for a path on this server
108
+ * @param {number} [options.timeoutMs] - how long to wait for an answer (default 30s)
109
+ * @param {Object} [options.log] - a logger, for what callers shouldn't show
110
+ */
111
+ constructor(server, options = {}) {
112
+ if (typeof server !== 'string' || server.trim() === '') {
113
+ throw new Error('A terminology server is required: an http(s) URL, or a path on this server such as /tx/r4');
114
+ }
115
+ const s = server.trim().replace(/\/+$/, '');
116
+ if (/^https?:\/\//i.test(s)) {
117
+ this.internal = false;
118
+ this.base = s;
119
+ } else if (s.startsWith('/')) {
120
+ if (typeof options.app !== 'function') {
121
+ throw new Error(`The terminology server '${server}' is a path on this server, which needs the Express app to route to`);
122
+ }
123
+ this.internal = true;
124
+ this.base = s;
125
+ this.app = options.app;
126
+ } else {
127
+ throw new Error(`The terminology server must be an http(s) URL or a path on this server, not '${server}'`);
128
+ }
129
+ this.timeoutMs = options.timeoutMs || 30000;
130
+ this.log = options.log || null;
131
+ }
132
+
133
+ /** Where the server is, for links and messages: a URL, or a path on this server. */
134
+ get url() {
135
+ return this.base;
136
+ }
137
+
138
+ /** The address of a code system on the server: its search page / search result. */
139
+ codeSystemLink(system) {
140
+ return `${this.base}/CodeSystem?url=${encodeURIComponent(system)}`;
141
+ }
142
+
143
+ /**
144
+ * A FHIR request to the server.
145
+ *
146
+ * @param {string} method - GET or POST
147
+ * @param {string} path - relative to the server, e.g. /CodeSystem/$validate-code
148
+ * @param {Object} [options]
149
+ * @param {Object} [options.query] - query parameters
150
+ * @param {Object} [options.body] - a resource (POST)
151
+ * @returns {Promise<{status: number, resource: Object|null, text: string}>} the parsed
152
+ * JSON when there is some
153
+ */
154
+ async request(method, path, options = {}) {
155
+ const qs = options.query ? new URLSearchParams(Object.entries(options.query).filter(([, v]) => v !== undefined && v !== null)).toString() : '';
156
+ const target = `${this.base}${path}${qs ? '?' + qs : ''}`;
157
+ const headers = { Accept: FHIR_JSON };
158
+ let body;
159
+ if (options.body !== undefined) {
160
+ headers['Content-Type'] = FHIR_JSON;
161
+ body = JSON.stringify(options.body);
162
+ }
163
+ let status;
164
+ let text;
165
+ if (this.internal) {
166
+ // a handler that never answers must not hang the caller (the pipeline runs one
167
+ // document at a time, and would stop for good)
168
+ let timer;
169
+ const r = await Promise.race([
170
+ dispatch(this.app, { method, url: target, headers, body }),
171
+ new Promise((_, reject) => {
172
+ timer = setTimeout(() => reject(new Error(`No answer from ${target} within ${this.timeoutMs}ms`)), this.timeoutMs);
173
+ })
174
+ ]).finally(() => clearTimeout(timer));
175
+ status = r.status;
176
+ text = r.body;
177
+ } else {
178
+ const r = await fetch(target, { method, headers, body, signal: AbortSignal.timeout(this.timeoutMs) });
179
+ status = r.status;
180
+ text = await r.text();
181
+ }
182
+ let resource = null;
183
+ if (text) {
184
+ try {
185
+ resource = JSON.parse(text);
186
+ } catch {
187
+ resource = null;
188
+ }
189
+ }
190
+ return { status, resource, text };
191
+ }
192
+
193
+ get(path, query) {
194
+ return this.request('GET', path, { query });
195
+ }
196
+
197
+ post(path, body) {
198
+ return this.request('POST', path, { body });
199
+ }
200
+ }
201
+
202
+ module.exports = { TxClient, dispatch };