fhirsmith 0.9.5 → 0.9.7
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/CHANGELOG.md +38 -0
- package/config-template.json +2 -1
- package/library/folder-content-loader.js +91 -0
- package/library/regex-utilities.js +49 -12
- package/npmprojector/npmprojector.js +2 -6
- package/package.json +2 -2
- package/publisher/publisher.js +105 -12
- package/registry/registry.js +6 -6
- package/server.js +6 -2
- package/test-scripts/repro-re2-wasm-leak.js +8 -7
- package/translations/Messages.properties +1 -1
- package/tx/cs/cs-cs.js +8 -0
- package/tx/cs/cs-loinc.js +13 -12
- package/tx/cs/cs-omop.js +24 -23
- package/tx/cs/cs-provider-list.js +2 -1
- package/tx/cs/cs-snomed.js +142 -59
- package/tx/cs/cs-unii.js +11 -11
- package/tx/data/snomed-testing.cache +0 -0
- package/tx/library/canonical-resource.js +4 -2
- package/tx/library/designations.js +27 -20
- package/tx/library/renderer.js +303 -22
- package/tx/library/ucum-types.js +4 -1
- package/tx/library.js +65 -21
- package/tx/operation-context.js +52 -23
- package/tx/params.js +36 -8
- package/tx/problems.js +0 -4
- package/tx/provider.js +7 -3
- package/tx/tx-html.js +7 -0
- package/tx/tx.js +24 -13
- package/tx/vs/vs-vsac.js +157 -9
- package/tx/workers/expand.js +100 -96
- package/tx/workers/lookup.js +6 -0
- package/tx/workers/read.js +1 -1
- package/tx/workers/translate.js +21 -29
- package/tx/workers/validate.js +18 -10
- package/tx/workers/worker.js +5 -1
- package/tx/xversion/xv-bundle.js +1 -2
- package/tx/xversion/xv-codesystem.js +5 -2
- package/tx/xversion/xv-parameters.js +4 -4
- package/tx/xversion/xv-resource.js +2 -2
- package/tx/xversion/xv-terminologyCapabilities.js +11 -6
- package/tx/xversion/xv-valueset.js +7 -7
- package/publisher/task-draft.js +0 -458
package/CHANGELOG.md
CHANGED
|
@@ -6,11 +6,49 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
## [v0.9.7] - 2026-06-12
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Support for tx modele to load resources directly
|
|
14
|
+
- Missing overwork protection processing ECL
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Fix bug validating secondary displays across languages
|
|
19
|
+
- Fix CORS issue (double headers)
|
|
20
|
+
- Fix czech code in snomed import
|
|
21
|
+
- Many minor validation fixes
|
|
22
|
+
- ECL processing bugs
|
|
23
|
+
- Publishing: better logging, and fix timeout & restart error
|
|
24
|
+
|
|
25
|
+
### Tx Conformance Statement
|
|
26
|
+
|
|
27
|
+
FHIRsmith passed all 2503 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.10)
|
|
28
|
+
|
|
29
|
+
## [v0.9.6] - 2026-05-21
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- increase timeout when publishing IGs (for US Core)
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Replace re2-wasm with re2js in library/regex-utilities.js to eliminate the underlying WASM-heap leak
|
|
38
|
+
- Fix async problem loading OMOP
|
|
39
|
+
- Fix memory leaks
|
|
40
|
+
- Don't leak database connections
|
|
41
|
+
|
|
42
|
+
### Tx Conformance Statement
|
|
43
|
+
|
|
44
|
+
FHIRsmith passed all 1649 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.7)
|
|
45
|
+
|
|
9
46
|
## [v0.9.5] - 2026-05-16
|
|
10
47
|
|
|
11
48
|
### Fixed
|
|
12
49
|
|
|
13
50
|
- Workaround for memory leak in re2-wasm library that reduces it's severity
|
|
51
|
+
- Replace re2-wasm with re2js in library/regex-utilities.js to eliminate the underlying WASM-heap leak
|
|
14
52
|
|
|
15
53
|
### Tx Conformance Statement
|
|
16
54
|
|
package/config-template.json
CHANGED
|
@@ -63,7 +63,8 @@
|
|
|
63
63
|
"enabled": true,
|
|
64
64
|
"database": "/absolute/path/to/publisher.db",
|
|
65
65
|
"sessionSecret": "change-this-to-a-secure-random-string",
|
|
66
|
-
"workspaceRoot": "/absolute/path/to/workspaces"
|
|
66
|
+
"workspaceRoot": "/absolute/path/to/workspaces",
|
|
67
|
+
"igPublisherTimeoutMinutes": 60
|
|
67
68
|
},
|
|
68
69
|
"npmprojector": {
|
|
69
70
|
"enabled": true,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const fs = require('fs').promises;
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { PackageContentLoader } = require('./package-manager');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A content loader that scans a folder for JSON FHIR terminology resources
|
|
7
|
+
* (CodeSystem, ValueSet, ConceptMap). It synthesizes the package layout that
|
|
8
|
+
* PackageContentLoader expects so the existing PackageValueSetProvider /
|
|
9
|
+
* PackageConceptMapProvider machinery can be reused without modification.
|
|
10
|
+
*
|
|
11
|
+
* The user's source folder is treated as read-only. Any SQLite caches built
|
|
12
|
+
* by the Package*Provider classes are written to the cacheFolder passed in,
|
|
13
|
+
* not to the source folder.
|
|
14
|
+
*/
|
|
15
|
+
class FolderContentLoader extends PackageContentLoader {
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} sourceFolder - Folder to scan for JSON resources
|
|
18
|
+
* @param {string} cacheFolder - Folder where Package*Provider DBs may live
|
|
19
|
+
*/
|
|
20
|
+
constructor(sourceFolder, cacheFolder) {
|
|
21
|
+
super(cacheFolder);
|
|
22
|
+
this.sourceFolder = sourceFolder;
|
|
23
|
+
// Resources are read directly from the source folder rather than from
|
|
24
|
+
// <packageFolder>/package as PackageContentLoader assumes.
|
|
25
|
+
this.packageSubfolder = sourceFolder;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Replaces the standard initialize: there is no package.json or .index.json
|
|
30
|
+
* to read, so we synthesize an index from the JSON files in the folder.
|
|
31
|
+
*/
|
|
32
|
+
async initialize() {
|
|
33
|
+
if (this.loaded) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const stat = await fs.stat(this.sourceFolder);
|
|
39
|
+
if (!stat.isDirectory()) {
|
|
40
|
+
throw new Error(`Folder source path is not a directory: ${this.sourceFolder}`);
|
|
41
|
+
}
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.code === 'ENOENT') {
|
|
44
|
+
throw new Error(`Folder source path does not exist: ${this.sourceFolder}`);
|
|
45
|
+
}
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Synthesize package metadata so id()/version()/pid() return something usable.
|
|
50
|
+
this.package = {
|
|
51
|
+
name: 'folder.' + path.basename(this.sourceFolder),
|
|
52
|
+
version: '0.0.0',
|
|
53
|
+
fhirVersions: ['5.0.0']
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const files = [];
|
|
57
|
+
const entries = await fs.readdir(this.sourceFolder, { withFileTypes: true });
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (!entry.isFile()) continue;
|
|
60
|
+
if (!entry.name.toLowerCase().endsWith('.json')) continue;
|
|
61
|
+
|
|
62
|
+
const fullPath = path.join(this.sourceFolder, entry.name);
|
|
63
|
+
let json;
|
|
64
|
+
try {
|
|
65
|
+
const content = await fs.readFile(fullPath, 'utf8');
|
|
66
|
+
json = JSON.parse(content);
|
|
67
|
+
} catch {
|
|
68
|
+
// Skip unreadable / non-JSON files rather than failing the whole load.
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!json || typeof json !== 'object') continue;
|
|
73
|
+
const rt = json.resourceType;
|
|
74
|
+
if (rt !== 'CodeSystem' && rt !== 'ValueSet' && rt !== 'ConceptMap') continue;
|
|
75
|
+
|
|
76
|
+
files.push({
|
|
77
|
+
filename: entry.name,
|
|
78
|
+
resourceType: rt,
|
|
79
|
+
id: json.id,
|
|
80
|
+
url: json.url,
|
|
81
|
+
version: json.version
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.index = { files, 'index-version': 2 };
|
|
86
|
+
this.buildIndexes();
|
|
87
|
+
this.loaded = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { FolderContentLoader };
|
|
@@ -1,27 +1,64 @@
|
|
|
1
|
-
|
|
1
|
+
const { RE2JS } = require('re2js');
|
|
2
|
+
|
|
3
|
+
// Translate a JS-style flags string ("i", "im", "is", etc.) into the bit-flag
|
|
4
|
+
// integer that RE2JS.compile() expects. Only the flags actually used by callers
|
|
5
|
+
// (and their natural counterparts) are mapped; anything else is ignored.
|
|
6
|
+
function toRE2JSFlags(flags) {
|
|
7
|
+
if (!flags) return 0;
|
|
8
|
+
let bits = 0;
|
|
9
|
+
if (flags.includes('i')) bits |= RE2JS.CASE_INSENSITIVE;
|
|
10
|
+
if (flags.includes('m')) bits |= RE2JS.MULTILINE;
|
|
11
|
+
if (flags.includes('s')) bits |= RE2JS.DOTALL;
|
|
12
|
+
// The 'u' flag was a re2-wasm workaround; re2js is pure JS and handles
|
|
13
|
+
// unicode natively, so it's a no-op here.
|
|
14
|
+
return bits;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Thin wrapper that exposes the only method the rest of the codebase uses
|
|
18
|
+
// against compiled regexes: a JS-RegExp-style `.test(input)` that returns
|
|
19
|
+
// true if the pattern is found anywhere in `input`.
|
|
20
|
+
class CompiledRegex {
|
|
21
|
+
constructor(pattern, flags) {
|
|
22
|
+
this._pattern = RE2JS.compile(pattern, toRE2JSFlags(flags));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test(input) {
|
|
26
|
+
if (input == null) return false;
|
|
27
|
+
return this._pattern.matcher(String(input)).find();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
2
30
|
|
|
3
31
|
class RegExUtilities {
|
|
4
32
|
|
|
33
|
+
// re2js doesn't have re2-wasm's WASM-heap leak, so the cache here is
|
|
34
|
+
// purely a performance optimisation. We cap it with a simple LRU so a
|
|
35
|
+
// workload with many distinct regex patterns can't grow it without bound.
|
|
36
|
+
static MAX_CACHE_SIZE = 1000;
|
|
37
|
+
|
|
5
38
|
constructor() {
|
|
6
39
|
this._cache = new Map();
|
|
7
40
|
}
|
|
8
41
|
|
|
9
42
|
compile(pattern, flags) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
43
|
+
const key = pattern + '|' + (flags || '');
|
|
44
|
+
const cached = this._cache.get(key);
|
|
45
|
+
if (cached) {
|
|
46
|
+
// Move to most-recently-used position by re-inserting.
|
|
47
|
+
this._cache.delete(key);
|
|
48
|
+
this._cache.set(key, cached);
|
|
49
|
+
return cached;
|
|
50
|
+
}
|
|
51
|
+
const compiled = new CompiledRegex(pattern, flags);
|
|
52
|
+
if (this._cache.size >= RegExUtilities.MAX_CACHE_SIZE) {
|
|
53
|
+
// Evict the oldest entry. Map iteration order is insertion order, so
|
|
54
|
+
// the first key is the least-recently-used.
|
|
55
|
+
const oldest = this._cache.keys().next().value;
|
|
56
|
+
this._cache.delete(oldest);
|
|
20
57
|
}
|
|
58
|
+
this._cache.set(key, compiled);
|
|
21
59
|
return compiled;
|
|
22
60
|
}
|
|
23
61
|
|
|
24
62
|
}
|
|
25
63
|
|
|
26
64
|
module.exports = new RegExUtilities();
|
|
27
|
-
|
|
@@ -127,12 +127,8 @@ class NpmProjectorModule {
|
|
|
127
127
|
* Set up Express routes
|
|
128
128
|
*/
|
|
129
129
|
setupRoutes() {
|
|
130
|
-
// CORS
|
|
131
|
-
|
|
132
|
-
res.header('Access-Control-Allow-Origin', '*');
|
|
133
|
-
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
|
|
134
|
-
next();
|
|
135
|
-
});
|
|
130
|
+
// CORS is handled once at the app level (server.js, from config.server.cors).
|
|
131
|
+
// Do not set Access-Control-* headers here.
|
|
136
132
|
|
|
137
133
|
// Root - module info
|
|
138
134
|
this.router.get('/', (req, res) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fhirsmith",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"txVersion": "1.9.1",
|
|
5
5
|
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
|
|
6
6
|
"main": "server.js",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"passport-github2": "^0.1.12",
|
|
62
62
|
"passport-google-oauth20": "^2.0.0",
|
|
63
63
|
"properties-file": "^3.6.4",
|
|
64
|
-
"
|
|
64
|
+
"re2js": "^2.8.0",
|
|
65
65
|
"rimraf": "^5.0.10",
|
|
66
66
|
"sqlite3": "^5.1.7",
|
|
67
67
|
"tar": "^7.5.7",
|
package/publisher/publisher.js
CHANGED
|
@@ -17,6 +17,7 @@ class PublisherModule {
|
|
|
17
17
|
this.logger = null;
|
|
18
18
|
this.taskProcessor = null;
|
|
19
19
|
this.isProcessing = false;
|
|
20
|
+
this.activeTaskIds = new Set();
|
|
20
21
|
this.shutdownRequested = false;
|
|
21
22
|
this.stats = stats;
|
|
22
23
|
}
|
|
@@ -308,13 +309,19 @@ class PublisherModule {
|
|
|
308
309
|
if (this.shutdownRequested) return;
|
|
309
310
|
|
|
310
311
|
if (this.isProcessing) {
|
|
312
|
+
// A publication can legitimately run for longer than an hour (go-publish on a
|
|
313
|
+
// large IG), so never reset isProcessing while a task is in flight — doing so
|
|
314
|
+
// lets the poller start a second, concurrent run of the same task, which then
|
|
315
|
+
// fails on 'ig-registry already exists' and stomps the real run's status.
|
|
316
|
+
// Recovery from a genuinely hung IG Publisher is handled by the
|
|
317
|
+
// igPublisherTimeoutMinutes kill in runIgPublisher/runPublisherGoPublish.
|
|
311
318
|
const stuckMs = this.isProcessingStarted ? Date.now() - this.isProcessingStarted : 0;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
this.
|
|
315
|
-
|
|
316
|
-
return;
|
|
319
|
+
const warnAfterMs = ((this.config.igPublisherTimeoutMinutes || 60) + 30) * 60 * 1000;
|
|
320
|
+
if (stuckMs > warnAfterMs) {
|
|
321
|
+
this.logger.warn('Task processor still busy after ' + Math.round(stuckMs / 60000) + ' min (tasks: ' +
|
|
322
|
+
Array.from(this.activeTaskIds).join(', ') + ') — possible hang, not starting new work');
|
|
317
323
|
}
|
|
324
|
+
return;
|
|
318
325
|
}
|
|
319
326
|
|
|
320
327
|
await this.processNextTask();
|
|
@@ -325,10 +332,14 @@ class PublisherModule {
|
|
|
325
332
|
this.isProcessing = true;
|
|
326
333
|
this.isProcessingStarted = Date.now();
|
|
327
334
|
|
|
335
|
+
let claimedTaskId = null;
|
|
328
336
|
try {
|
|
329
337
|
// Look for queued tasks first (draft builds)
|
|
330
338
|
let task = await this.getNextQueuedTask();
|
|
331
339
|
if (task) {
|
|
340
|
+
if (this.activeTaskIds.has(task.id)) return; // already being processed
|
|
341
|
+
claimedTaskId = task.id;
|
|
342
|
+
this.activeTaskIds.add(task.id);
|
|
332
343
|
this.stats.task('Publisher', 'Building ' + task.npm_package_id + '#' + task.version);
|
|
333
344
|
await this.processDraftBuild(task);
|
|
334
345
|
this.stats.taskDone('Publisher', 'Built ' + task.npm_package_id + '#' + task.version);
|
|
@@ -338,6 +349,9 @@ class PublisherModule {
|
|
|
338
349
|
// Then look for approved tasks (publishing)
|
|
339
350
|
task = await this.getNextApprovedTask();
|
|
340
351
|
if (task) {
|
|
352
|
+
if (this.activeTaskIds.has(task.id)) return; // already being processed
|
|
353
|
+
claimedTaskId = task.id;
|
|
354
|
+
this.activeTaskIds.add(task.id);
|
|
341
355
|
this.stats.task('Publisher', 'Publishing ' + task.npm_package_id + '#' + task.version);
|
|
342
356
|
await this.processPublication(task);
|
|
343
357
|
this.stats.taskDone('Publisher', 'Published ' + task.npm_package_id + '#' + task.version);
|
|
@@ -349,6 +363,9 @@ class PublisherModule {
|
|
|
349
363
|
this.logger.error('Error in task processor:', error);
|
|
350
364
|
this.stats.taskError('Publisher', 'Error: ' + error.message);
|
|
351
365
|
} finally {
|
|
366
|
+
if (claimedTaskId !== null) {
|
|
367
|
+
this.activeTaskIds.delete(claimedTaskId);
|
|
368
|
+
}
|
|
352
369
|
this.isProcessing = false;
|
|
353
370
|
}
|
|
354
371
|
}
|
|
@@ -390,6 +407,9 @@ class PublisherModule {
|
|
|
390
407
|
fields.push('waiting_approval_at = CURRENT_TIMESTAMP');
|
|
391
408
|
} else if (status === 'complete') {
|
|
392
409
|
fields.push('completed_at = CURRENT_TIMESTAMP');
|
|
410
|
+
// A successful completion invalidates any earlier failure (e.g. from a
|
|
411
|
+
// superseded duplicate run) — don't let a stale message outlive success.
|
|
412
|
+
fields.push('failure_reason = NULL');
|
|
393
413
|
} else if (status === 'failed') {
|
|
394
414
|
fields.push('failed_at = CURRENT_TIMESTAMP');
|
|
395
415
|
}
|
|
@@ -414,6 +434,28 @@ class PublisherModule {
|
|
|
414
434
|
});
|
|
415
435
|
}
|
|
416
436
|
|
|
437
|
+
// Update arbitrary task fields WITHOUT touching status. Use this from long-running
|
|
438
|
+
// work (e.g. saving the announcement mid-publication) — writing back the in-memory
|
|
439
|
+
// task.status can resurrect a stale status and cause the poller to re-run the task.
|
|
440
|
+
async updateTaskFields(taskId, fields) {
|
|
441
|
+
const keys = Object.keys(fields);
|
|
442
|
+
if (keys.length === 0) return;
|
|
443
|
+
const assignments = keys.map(key => key + ' = ?');
|
|
444
|
+
const values = keys.map(key => fields[key]);
|
|
445
|
+
values.push(taskId);
|
|
446
|
+
|
|
447
|
+
return new Promise((resolve, reject) => {
|
|
448
|
+
this.db.run(
|
|
449
|
+
'UPDATE tasks SET ' + assignments.join(', ') + ' WHERE id = ?',
|
|
450
|
+
values,
|
|
451
|
+
(err) => {
|
|
452
|
+
if (err) reject(err);
|
|
453
|
+
else resolve();
|
|
454
|
+
}
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
417
459
|
async logTaskMessage(taskId, level, message) {
|
|
418
460
|
return new Promise((resolve) => {
|
|
419
461
|
this.db.run(
|
|
@@ -622,14 +664,38 @@ class PublisherModule {
|
|
|
622
664
|
// Create log file stream
|
|
623
665
|
const logStream = fs.createWriteStream(logFile);
|
|
624
666
|
|
|
667
|
+
const buildStart = Date.now();
|
|
668
|
+
let lastDataAt = Date.now();
|
|
669
|
+
|
|
625
670
|
java.stdout.on('data', (data) => {
|
|
671
|
+
lastDataAt = Date.now();
|
|
626
672
|
logStream.write(data);
|
|
627
673
|
});
|
|
628
674
|
|
|
629
675
|
java.stderr.on('data', (data) => {
|
|
676
|
+
lastDataAt = Date.now();
|
|
630
677
|
logStream.write(data);
|
|
631
678
|
});
|
|
632
679
|
|
|
680
|
+
// Heartbeat: emit a status line every 60s regardless of stdout activity,
|
|
681
|
+
// so silent phases of the Publisher (e.g. "Validating Resources") still
|
|
682
|
+
// surface a signal-of-life in the task log.
|
|
683
|
+
const heartbeat = setInterval(async () => {
|
|
684
|
+
const elapsedMs = Date.now() - buildStart;
|
|
685
|
+
const sinceDataMs = Date.now() - lastDataAt;
|
|
686
|
+
let logKb = 0;
|
|
687
|
+
try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) {}
|
|
688
|
+
const elapsedMin = Math.floor(elapsedMs / 60000);
|
|
689
|
+
const elapsedSec = Math.floor(elapsedMs / 1000) % 60;
|
|
690
|
+
const idleSec = Math.floor(sinceDataMs / 1000);
|
|
691
|
+
await this.logTaskMessage(
|
|
692
|
+
taskId,
|
|
693
|
+
'info',
|
|
694
|
+
'IG Publisher heartbeat: elapsed ' + elapsedMin + 'm' + elapsedSec + 's, ' +
|
|
695
|
+
'log ' + logKb + ' KB, last output ' + idleSec + 's ago'
|
|
696
|
+
);
|
|
697
|
+
}, 60 * 1000);
|
|
698
|
+
|
|
633
699
|
java.on('close', async (code) => {
|
|
634
700
|
logStream.end();
|
|
635
701
|
|
|
@@ -649,16 +715,18 @@ class PublisherModule {
|
|
|
649
715
|
reject(error);
|
|
650
716
|
});
|
|
651
717
|
|
|
652
|
-
// Timeout
|
|
718
|
+
// Timeout configurable via publisher.igPublisherTimeoutMinutes (default: 60 minutes)
|
|
719
|
+
const timeoutMinutes = this.config.igPublisherTimeoutMinutes || 60;
|
|
653
720
|
const timeout = setTimeout(async () => {
|
|
654
721
|
java.kill();
|
|
655
722
|
logStream.end();
|
|
656
|
-
await this.logTaskMessage(taskId, 'error', 'IG Publisher timed out after
|
|
723
|
+
await this.logTaskMessage(taskId, 'error', 'IG Publisher timed out after ' + timeoutMinutes + ' minutes');
|
|
657
724
|
reject(new Error('IG Publisher timed out'));
|
|
658
|
-
},
|
|
725
|
+
}, timeoutMinutes * 60 * 1000);
|
|
659
726
|
|
|
660
727
|
java.on('close', () => {
|
|
661
728
|
clearTimeout(timeout);
|
|
729
|
+
clearInterval(heartbeat);
|
|
662
730
|
});
|
|
663
731
|
});
|
|
664
732
|
}
|
|
@@ -788,7 +856,7 @@ class PublisherModule {
|
|
|
788
856
|
if (fs.existsSync(announcementPath)) {
|
|
789
857
|
try {
|
|
790
858
|
const announcement = fs.readFileSync(announcementPath, 'utf8');
|
|
791
|
-
await this.
|
|
859
|
+
await this.updateTaskFields(task.id, { announcement: announcement });
|
|
792
860
|
await this.logTaskMessage(task.id, 'info', 'Announcement text saved (' + announcement.length + ' chars)');
|
|
793
861
|
} catch (err) {
|
|
794
862
|
await this.logTaskMessage(task.id, 'warn', 'Failed to read announcement file: ' + err.message);
|
|
@@ -829,14 +897,37 @@ class PublisherModule {
|
|
|
829
897
|
|
|
830
898
|
const logStream = fs.createWriteStream(logFile);
|
|
831
899
|
|
|
900
|
+
const buildStart = Date.now();
|
|
901
|
+
let lastDataAt = Date.now();
|
|
902
|
+
|
|
832
903
|
java.stdout.on('data', (data) => {
|
|
904
|
+
lastDataAt = Date.now();
|
|
833
905
|
logStream.write(data);
|
|
834
906
|
});
|
|
835
907
|
|
|
836
908
|
java.stderr.on('data', (data) => {
|
|
909
|
+
lastDataAt = Date.now();
|
|
837
910
|
logStream.write(data);
|
|
838
911
|
});
|
|
839
912
|
|
|
913
|
+
// Heartbeat: emit a status line every 60s regardless of stdout activity,
|
|
914
|
+
// so silent phases of the Publisher still surface a signal-of-life.
|
|
915
|
+
const heartbeat = setInterval(async () => {
|
|
916
|
+
const elapsedMs = Date.now() - buildStart;
|
|
917
|
+
const sinceDataMs = Date.now() - lastDataAt;
|
|
918
|
+
let logKb = 0;
|
|
919
|
+
try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) {}
|
|
920
|
+
const elapsedMin = Math.floor(elapsedMs / 60000);
|
|
921
|
+
const elapsedSec = Math.floor(elapsedMs / 1000) % 60;
|
|
922
|
+
const idleSec = Math.floor(sinceDataMs / 1000);
|
|
923
|
+
await this.logTaskMessage(
|
|
924
|
+
taskId,
|
|
925
|
+
'info',
|
|
926
|
+
'IG Publisher go-publish heartbeat: elapsed ' + elapsedMin + 'm' + elapsedSec + 's, ' +
|
|
927
|
+
'log ' + logKb + ' KB, last output ' + idleSec + 's ago'
|
|
928
|
+
);
|
|
929
|
+
}, 60 * 1000);
|
|
930
|
+
|
|
840
931
|
java.on('close', async (code) => {
|
|
841
932
|
logStream.end();
|
|
842
933
|
if (code === 0) {
|
|
@@ -855,16 +946,18 @@ class PublisherModule {
|
|
|
855
946
|
reject(error);
|
|
856
947
|
});
|
|
857
948
|
|
|
858
|
-
// Timeout
|
|
949
|
+
// Timeout configurable via publisher.igPublisherTimeoutMinutes (default: 60 minutes)
|
|
950
|
+
const timeoutMinutes = this.config.igPublisherTimeoutMinutes || 60;
|
|
859
951
|
const timeout = setTimeout(async () => {
|
|
860
952
|
java.kill();
|
|
861
953
|
logStream.end();
|
|
862
|
-
await this.logTaskMessage(taskId, 'error', 'IG Publisher go-publish timed out after
|
|
954
|
+
await this.logTaskMessage(taskId, 'error', 'IG Publisher go-publish timed out after ' + timeoutMinutes + ' minutes');
|
|
863
955
|
reject(new Error('IG Publisher go-publish timed out'));
|
|
864
|
-
},
|
|
956
|
+
}, timeoutMinutes * 60 * 1000);
|
|
865
957
|
|
|
866
958
|
java.on('close', () => {
|
|
867
959
|
clearTimeout(timeout);
|
|
960
|
+
clearInterval(heartbeat);
|
|
868
961
|
});
|
|
869
962
|
});
|
|
870
963
|
}
|
package/registry/registry.js
CHANGED
|
@@ -758,7 +758,7 @@ class RegistryModule {
|
|
|
758
758
|
case '11000267109': edition = 'KR'; break;
|
|
759
759
|
case '900000001000122104': edition = 'ES-ES'; break;
|
|
760
760
|
case '2011000195101': edition = 'CH'; break;
|
|
761
|
-
case '11000279109': edition = '
|
|
761
|
+
case '11000279109': edition = 'CZ'; break;
|
|
762
762
|
case '999000021000000109': edition = 'UK+Clinical'; break;
|
|
763
763
|
case '5631000179106': edition = 'UY'; break;
|
|
764
764
|
case '21000325107': edition = 'CL'; break;
|
|
@@ -1233,7 +1233,7 @@ class RegistryModule {
|
|
|
1233
1233
|
// FHIR Version field
|
|
1234
1234
|
html += '<p>';
|
|
1235
1235
|
html += '<label for="fhirVersion" class="form-label fw-bold">FHIR Version <span class="text-danger">*</span></label>';
|
|
1236
|
-
html += `<input type="text" class="form-control" id="fhirVersion" name="fhirVersion" size="8"
|
|
1236
|
+
html += `<input type="text" class="form-control" id="fhirVersion" name="fhirVersion" size="8"
|
|
1237
1237
|
value="${escape(fhirVersion)}" required>`;
|
|
1238
1238
|
html += '</p>';
|
|
1239
1239
|
html += '<p class="text-muted small">Examples: R4, 4.0.1, 5.0.0, etc.</p>';
|
|
@@ -1241,7 +1241,7 @@ class RegistryModule {
|
|
|
1241
1241
|
html += '<div class="alert alert-info">Either Code System URL or Value Set URL must be provided:</div>';
|
|
1242
1242
|
html += '<p>';
|
|
1243
1243
|
html += '<label for="url" class="form-label fw-bold">Code System URL</label>';
|
|
1244
|
-
html += `<input type="url" class="form-control" id="url" name="url"
|
|
1244
|
+
html += `<input type="url" class="form-control" id="url" name="url"
|
|
1245
1245
|
value="${escape(url)}">`;
|
|
1246
1246
|
html += '</p>';
|
|
1247
1247
|
html += '<p class="text-muted small">Example: http://loinc.org</p>';
|
|
@@ -1249,7 +1249,7 @@ class RegistryModule {
|
|
|
1249
1249
|
// ValueSet URL field - now vertical
|
|
1250
1250
|
html += '<p>';
|
|
1251
1251
|
html += '<label for="valueSet" class="form-label fw-bold">Value Set URL</label>';
|
|
1252
|
-
html += `<input type="url" class="form-control" id="valueSet" name="valueSet"
|
|
1252
|
+
html += `<input type="url" class="form-control" id="valueSet" name="valueSet"
|
|
1253
1253
|
value="${escape(valueSet)}">`;
|
|
1254
1254
|
html += '</p>';
|
|
1255
1255
|
html += '<p class="text-muted small">Example: http://hl7.org/fhir/ValueSet/observation-codes</p>';
|
|
@@ -1257,7 +1257,7 @@ class RegistryModule {
|
|
|
1257
1257
|
|
|
1258
1258
|
// Authoritative Only checkbox
|
|
1259
1259
|
html += '<p>';
|
|
1260
|
-
html += `<input type="checkbox" class="form-check-input" id="authoritativeOnly"
|
|
1260
|
+
html += `<input type="checkbox" class="form-check-input" id="authoritativeOnly"
|
|
1261
1261
|
name="authoritativeOnly" value="true" ${authoritativeOnly ? 'checked' : ''}>`;
|
|
1262
1262
|
html += '<label class="form-check-label" for="authoritativeOnly"> Show only authoritative servers</label>';
|
|
1263
1263
|
html += '</p>';
|
|
@@ -1276,7 +1276,7 @@ class RegistryModule {
|
|
|
1276
1276
|
document.querySelector('form').addEventListener('submit', function(e) {
|
|
1277
1277
|
const url = document.getElementById('url').value.trim();
|
|
1278
1278
|
const valueSet = document.getElementById('valueSet').value.trim();
|
|
1279
|
-
|
|
1279
|
+
|
|
1280
1280
|
if (!url && !valueSet) {
|
|
1281
1281
|
e.preventDefault();
|
|
1282
1282
|
alert('You must provide either a Code System URL or a Value Set URL');
|
package/server.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
|
|
9
9
|
const express = require('express');
|
|
10
|
-
|
|
10
|
+
const cors = require('cors');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const os = require('os');
|
|
@@ -82,7 +82,11 @@ app.use((req, res, next) => {
|
|
|
82
82
|
next();
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
// app
|
|
85
|
+
// Single, app-level CORS policy for all modules, driven by config.server.cors.
|
|
86
|
+
// This also handles OPTIONS preflight, so modules must NOT set their own
|
|
87
|
+
// Access-Control-* headers (doing so stacks a second CORS layer and produces
|
|
88
|
+
// duplicate, conflicting headers that browsers reject).
|
|
89
|
+
app.use(cors(config.server.cors));
|
|
86
90
|
|
|
87
91
|
// Module instances
|
|
88
92
|
const modules = {};
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Historical reproducer for the re2-wasm WASM heap leak.
|
|
2
|
+
// regex-utilities.js now sits on top of re2js (a pure-JS RE2 port), so the
|
|
3
|
+
// underlying leak is gone. Kept as a stress test for the compile cache:
|
|
3
4
|
//
|
|
4
5
|
// node test-scripts/repro-re2-wasm-leak.js # same-pattern stress
|
|
5
6
|
// node test-scripts/repro-re2-wasm-leak.js --unique # unique-pattern stress
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// Background (re2-wasm era): without the cache, same-pattern OOMed at ~2965
|
|
9
|
+
// iterations; with the cache, same-pattern ran indefinitely but unique-pattern
|
|
10
|
+
// still OOMed because every distinct pattern was a real compile and re2-wasm
|
|
11
|
+
// could not free its WASM heap. Under re2js both modes now run indefinitely
|
|
12
|
+
// (unique-pattern is bounded only by ordinary V8 heap growth from the cache).
|
|
12
13
|
|
|
13
14
|
const re = require('../library/regex-utilities');
|
|
14
15
|
|
|
@@ -544,7 +544,7 @@ NO_VALID_DISPLAY_FOUND_LANG_NONE_other = ''{5}'' is the default display; the cod
|
|
|
544
544
|
NO_VALID_DISPLAY_FOUND_LANG_SOME_one = ''{5}'' is the default display; no valid Display Names found for {1}#{2} in the language {4}
|
|
545
545
|
NO_VALID_DISPLAY_FOUND_LANG_SOME_other = ''{5}'' is the default display; no valid Display Names found for {1}#{2} in the language {4}
|
|
546
546
|
NO_VALID_DISPLAY_FOUND_NONE_FOR_LANG_ERR = Wrong Display Name ''{0}'' for {1}#{2}. There are no valid display names found for language(s) ''{3}''. Default display is ''{4}''
|
|
547
|
-
NO_VALID_DISPLAY_FOUND_NONE_FOR_LANG_OK = There are no valid display names found for the code {1}#{2} for language(s) ''{3}''. The display is ''{
|
|
547
|
+
NO_VALID_DISPLAY_FOUND_NONE_FOR_LANG_OK = There are no valid display names found for the code {1}#{2} for language(s) ''{3}''. The display is ''{0}'' which is a valid display for the default language
|
|
548
548
|
NO_VALID_DISPLAY_FOUND_one = No valid Display Names found for {1}#{2} in the language {4}
|
|
549
549
|
NO_VALID_DISPLAY_FOUND_other = No valid Display Names found for {1}#{2} in the languages {4}
|
|
550
550
|
No_validator_configured = No validator configured
|