fhirsmith 0.18.2 → 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 +149 -0
- 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 +132 -59
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +74 -21
- package/ips/presenter.js +1 -1
- package/ips/readme.md +132 -25
- package/ips/render.js +466 -77
- package/ips/store.js +79 -22
- 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/crawler.js +84 -108
- package/registry/documentation/configuration.md +34 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +5 -4
- 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 +154 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +29 -1
- 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 +148 -14
- 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 +185 -66
- 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 +608 -64
- 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 +57 -58
- 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/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 +278 -42
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/ecl.js +47 -6
- 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 +166 -13
- 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 +27 -4
- package/tx/vs/vs-vsac.js +648 -100
- 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,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cluster's state now, for the master's status panel (/ and /dashboard) and
|
|
3
|
+
* /dashboard/status.json (cluster.md, "Status").
|
|
4
|
+
*
|
|
5
|
+
* Under the launcher (cluster.js), every process sends a few figures of its own over its IPC
|
|
6
|
+
* channel every statusSeconds (15): its JS heap, its client caches and their concepts, its
|
|
7
|
+
* expansion cache, the requests it has in flight and whether its memory guard is refusing.
|
|
8
|
+
* The launcher adds what only it can read: each process's private memory and mapped file
|
|
9
|
+
* pages (/proc/<pid>/status), its status and restarts, and the cgroup's private memory and
|
|
10
|
+
* page cache (memory.stat), against the limit the memory guard uses. It merges them into one
|
|
11
|
+
* snapshot (buildSnapshot) and passes it to the master, as it passes on the request counts
|
|
12
|
+
* (library/cluster-counts.js). Each figure carries its scope, and its age.
|
|
13
|
+
*
|
|
14
|
+
* A worker's own page shows its own figures; a process without the launcher (no role, or roles
|
|
15
|
+
* started by hand behind nginx) has no IPC channel, and shows only its own.
|
|
16
|
+
*
|
|
17
|
+
* clusterSummary and processSummary shape the status line at the top of the page
|
|
18
|
+
* (tx/html/status-line.liquid): requests, uptime, heap and any problems. clusterView,
|
|
19
|
+
* processView and storageView shape the rest, under "More" (tx/html/status-panel.liquid):
|
|
20
|
+
* a header, tiles, the memory meter, a row per process and footnotes.
|
|
21
|
+
*/
|
|
22
|
+
const v8 = require('v8');
|
|
23
|
+
|
|
24
|
+
// a process's own figures, to the launcher; the launcher's snapshot, to the master
|
|
25
|
+
const STATUS = 'fhirsmith:status';
|
|
26
|
+
const CLUSTER_STATUS = 'fhirsmith:cluster-status';
|
|
27
|
+
// how often each process sends its figures, and the launcher sends the master its snapshot
|
|
28
|
+
// (server.cluster.statusSeconds)
|
|
29
|
+
const STATUS_INTERVAL_MS = 15000;
|
|
30
|
+
// figures this many intervals old are shown with their age, and left out of the totals
|
|
31
|
+
const STALE_INTERVALS = 3;
|
|
32
|
+
|
|
33
|
+
const n = (v) => Number(v).toLocaleString('en-US');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* This process's figures, as it sends them to the launcher.
|
|
37
|
+
* @param {Object} stats - the ServerStats
|
|
38
|
+
* @param {Object} own - { inFlight(), refusing() }, from server.js
|
|
39
|
+
*/
|
|
40
|
+
function ownFigures(stats, own = {}) {
|
|
41
|
+
const heap = process.memoryUsage();
|
|
42
|
+
return {
|
|
43
|
+
time: Date.now(),
|
|
44
|
+
requests: stats.requestCount,
|
|
45
|
+
heapUsed: heap.heapUsed,
|
|
46
|
+
heapLimit: v8.getHeapStatistics().heap_size_limit,
|
|
47
|
+
clientCaches: stats.clientCaches(),
|
|
48
|
+
clientConcepts: stats.clientConcepts(),
|
|
49
|
+
expansionItems: stats.expansionItems(),
|
|
50
|
+
inFlight: own.inFlight ? own.inFlight() : null,
|
|
51
|
+
refusing: own.refusing ? own.refusing() : null
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Attach a process with a role to the launcher's IPC channel: it sends its own figures every
|
|
57
|
+
* intervalMs, and the master keeps the snapshot the launcher sends back (stats.clusterStatus).
|
|
58
|
+
* A process started without the launcher has no channel, and this does nothing.
|
|
59
|
+
* @param {Object} stats - the ServerStats
|
|
60
|
+
* @param {Object} [channel] - process, or a stand-in (tests)
|
|
61
|
+
* @param {Object} [options] - role, intervalMs, figures() (what to send)
|
|
62
|
+
* @returns {Function} disconnect
|
|
63
|
+
*/
|
|
64
|
+
function connectStatus(stats, channel = process, { role = null, intervalMs = STATUS_INTERVAL_MS, figures } = {}) {
|
|
65
|
+
if (typeof channel.send !== 'function') {
|
|
66
|
+
return () => {};
|
|
67
|
+
}
|
|
68
|
+
let sending = false;
|
|
69
|
+
const send = () => {
|
|
70
|
+
// a figure that can't be sent is simply replaced by the next
|
|
71
|
+
if (sending || channel.connected === false) return;
|
|
72
|
+
let message;
|
|
73
|
+
try {
|
|
74
|
+
message = { type: STATUS, figures: figures() };
|
|
75
|
+
} catch {
|
|
76
|
+
return; // statistics must never stop the server
|
|
77
|
+
}
|
|
78
|
+
sending = true;
|
|
79
|
+
try {
|
|
80
|
+
channel.send(message, () => { sending = false; });
|
|
81
|
+
} catch {
|
|
82
|
+
sending = false;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const timer = setInterval(send, intervalMs > 0 ? intervalMs : STATUS_INTERVAL_MS);
|
|
86
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
87
|
+
send();
|
|
88
|
+
const receive = message => {
|
|
89
|
+
if (role === 'master' && message && message.type === CLUSTER_STATUS && message.snapshot) {
|
|
90
|
+
stats.clusterStatus = message.snapshot;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
// a master that has lost the launcher shows its own figures again
|
|
94
|
+
const disconnected = () => { stats.clusterStatus = null; };
|
|
95
|
+
channel.on('message', receive);
|
|
96
|
+
channel.on('disconnect', disconnected);
|
|
97
|
+
return () => {
|
|
98
|
+
clearInterval(timer);
|
|
99
|
+
channel.removeListener('message', receive);
|
|
100
|
+
channel.removeListener('disconnect', disconnected);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The launcher's snapshot of the cluster: what each process last sent, with what the launcher
|
|
106
|
+
* read itself. Totals are of the processes whose figures are fresh (STALE_INTERVALS); a
|
|
107
|
+
* figure that couldn't be read is null, and `missing` says what isn't known and why.
|
|
108
|
+
* @param {Object} input
|
|
109
|
+
* @param {number} input.now
|
|
110
|
+
* @param {number} input.intervalMs - statusSeconds, in ms
|
|
111
|
+
* @param {Object[]} input.procs - code, role, status, restarts, startedAt (0: not running),
|
|
112
|
+
* figures (what it last sent, or null), memory ({anon, file} from /proc, or null)
|
|
113
|
+
* @param {Object} input.launcher - startedAt, requests (proxied), memory ({anon, file}|null)
|
|
114
|
+
* @param {Object} input.guard - the memory guard's limit and thresholds (thresholds() in
|
|
115
|
+
* library/memory-guard.js): limit, source, rejectFraction, abortFraction, rejectAt, abortAt,
|
|
116
|
+
* and group (whether it measures the cgroup)
|
|
117
|
+
* @param {Object|null} input.cgroup - {anon, file} from memory.stat; null: not measured
|
|
118
|
+
* @param {boolean} [input.sharedCache] - server.cluster.sharedCache: the client caches are one
|
|
119
|
+
* store, and the count is read once from it (the master's figure) rather than summed
|
|
120
|
+
* @param {boolean} [input.procAvailable] - whether /proc could be read at all
|
|
121
|
+
*/
|
|
122
|
+
function buildSnapshot({ now, intervalMs, procs, launcher, guard, cgroup, sharedCache = false, procAvailable = true }) {
|
|
123
|
+
const missing = [];
|
|
124
|
+
const staleMs = STALE_INTERVALS * intervalMs;
|
|
125
|
+
const processes = procs.map(p => {
|
|
126
|
+
const f = p.figures;
|
|
127
|
+
const age = f && Number.isFinite(f.time) ? Math.max(0, now - f.time) : null;
|
|
128
|
+
const fresh = f && age !== null && age <= staleMs;
|
|
129
|
+
return {
|
|
130
|
+
code: p.code, role: p.role,
|
|
131
|
+
label: `${p.role === 'master' ? 'Master' : 'Worker'} ${p.code}`,
|
|
132
|
+
status: p.status, restarts: p.restarts || 0,
|
|
133
|
+
uptimeMs: p.startedAt ? now - p.startedAt : null,
|
|
134
|
+
anon: p.memory ? p.memory.anon : null,
|
|
135
|
+
file: p.memory ? p.memory.file : null,
|
|
136
|
+
figuresAgeMs: age, fresh: Boolean(fresh),
|
|
137
|
+
requests: f ? f.requests ?? null : null,
|
|
138
|
+
heapUsed: f ? f.heapUsed ?? null : null,
|
|
139
|
+
heapLimit: f ? f.heapLimit ?? null : null,
|
|
140
|
+
clientCaches: f ? f.clientCaches ?? null : null,
|
|
141
|
+
clientConcepts: f ? f.clientConcepts ?? null : null,
|
|
142
|
+
expansionItems: f ? f.expansionItems ?? null : null,
|
|
143
|
+
inFlight: f ? f.inFlight ?? null : null,
|
|
144
|
+
refusing: f ? f.refusing ?? null : null
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
const fresh = processes.filter(p => p.fresh);
|
|
148
|
+
const sum = (list, field) => list.some(p => Number.isFinite(p[field]))
|
|
149
|
+
? list.reduce((a, p) => a + (Number.isFinite(p[field]) ? p[field] : 0), 0) : null;
|
|
150
|
+
const running = processes.filter(p => p.uptimeMs !== null);
|
|
151
|
+
if (fresh.length < running.length) {
|
|
152
|
+
missing.push(`${running.length - fresh.length} running process(es) have sent no figures in the last ${Math.round(staleMs / 1000)} s: the totals leave them out`);
|
|
153
|
+
}
|
|
154
|
+
if (!procAvailable) {
|
|
155
|
+
missing.push('per-process memory: /proc is not available here (Linux only)');
|
|
156
|
+
}
|
|
157
|
+
if (!cgroup) {
|
|
158
|
+
missing.push(guard.group
|
|
159
|
+
? 'the cgroup\'s memory: memory.stat could not be read'
|
|
160
|
+
: 'the cgroup\'s memory: there is no cgroup with a memory limit of the service\'s own (MemoryMax on the unit, or a container limit), so the memory guard measures each process alone, and the page cache is not known');
|
|
161
|
+
}
|
|
162
|
+
// client caches: one store with sharedCache (any process's count is the whole), else each its own
|
|
163
|
+
let caches;
|
|
164
|
+
if (sharedCache) {
|
|
165
|
+
const from = fresh.find(p => p.role === 'master' && Number.isFinite(p.clientCaches)) || fresh.find(p => Number.isFinite(p.clientCaches));
|
|
166
|
+
caches = { scope: 'shared', from: from ? from.code : null,
|
|
167
|
+
clientCaches: from ? from.clientCaches : null, clientConcepts: from ? from.clientConcepts : null };
|
|
168
|
+
} else {
|
|
169
|
+
caches = { scope: 'summed', clientCaches: sum(fresh, 'clientCaches'), clientConcepts: sum(fresh, 'clientConcepts') };
|
|
170
|
+
}
|
|
171
|
+
const memoryOfProcs = processes.filter(p => Number.isFinite(p.anon));
|
|
172
|
+
return {
|
|
173
|
+
sampledAt: now,
|
|
174
|
+
intervalMs,
|
|
175
|
+
memory: {
|
|
176
|
+
// what the memory guard measures, with a role: the cgroup's private memory
|
|
177
|
+
cgroup: cgroup ? { anon: cgroup.anon, file: cgroup.file } : null,
|
|
178
|
+
processAnon: memoryOfProcs.length ? memoryOfProcs.reduce((a, p) => a + p.anon, 0) + (launcher.memory ? launcher.memory.anon : 0) : null,
|
|
179
|
+
limit: guard.limit, limitSource: guard.source,
|
|
180
|
+
rejectAt: guard.rejectAt, abortAt: guard.abortAt,
|
|
181
|
+
rejectFraction: guard.rejectFraction, abortFraction: guard.abortFraction,
|
|
182
|
+
group: Boolean(guard.group)
|
|
183
|
+
},
|
|
184
|
+
caches: { ...caches, expansionItems: sum(fresh, 'expansionItems') },
|
|
185
|
+
inFlight: sum(fresh, 'inFlight'),
|
|
186
|
+
refusing: fresh.filter(p => p.refusing === true).map(p => p.code),
|
|
187
|
+
processes,
|
|
188
|
+
launcher: {
|
|
189
|
+
label: 'Launcher', status: 'running',
|
|
190
|
+
uptimeMs: launcher.startedAt ? now - launcher.startedAt : null,
|
|
191
|
+
requests: launcher.requests ?? null,
|
|
192
|
+
anon: launcher.memory ? launcher.memory.anon : null,
|
|
193
|
+
file: launcher.memory ? launcher.memory.file : null,
|
|
194
|
+
heapUsed: launcher.heapUsed ?? null, heapLimit: launcher.heapLimit ?? null
|
|
195
|
+
},
|
|
196
|
+
missing
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function ago(ms) {
|
|
201
|
+
if (ms === null || ms === undefined) return '—';
|
|
202
|
+
return ms < 1000 ? 'now' : `${Math.round(ms / 1000)} s`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function duration(ms) {
|
|
206
|
+
if (ms === null || ms === undefined) return '—';
|
|
207
|
+
const s = Math.floor(ms / 1000);
|
|
208
|
+
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
|
|
209
|
+
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${s % 60}s` : `${s}s`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// "—" where a figure doesn't apply or isn't known
|
|
213
|
+
const dash = (v, f = String) => (v === null || v === undefined ? '—' : f(v));
|
|
214
|
+
const mb = (b) => Math.round(b / 2 ** 20).toLocaleString('en-US');
|
|
215
|
+
const gb = (b) => (b / 2 ** 30).toFixed(1);
|
|
216
|
+
// a rate: whole numbers from 100 up, one decimal below
|
|
217
|
+
const rate = (v) => (v >= 100 ? n(Math.round(v)) : n(Number(v.toFixed(1))));
|
|
218
|
+
const pct = (part, whole) => (whole > 0 ? Math.max(0, Math.min(100, (part * 100) / whole)) : 0);
|
|
219
|
+
|
|
220
|
+
// The graph lines' colours, in their order (tx/html/cluster-metrics-script.liquid): told apart
|
|
221
|
+
// by lightness, and every other segment of the memory meter is also striped
|
|
222
|
+
const PALETTE = ['#08306b', '#5b9bd5', '#d98c00', '#6a3d9a', '#9e9e9e', '#8a5a00', '#b4a7d6', '#404040'];
|
|
223
|
+
const SYMBOLS = ['●', '■', '▲', '◆', '★', '✕', '✚', '▢'];
|
|
224
|
+
// the launcher, and the rest of a cgroup the processes don't account for: light, hatched
|
|
225
|
+
const NEUTRAL = '#d9d9d9';
|
|
226
|
+
const swatch = (i) => ({ color: PALETTE[i % PALETTE.length], symbol: SYMBOLS[i % SYMBOLS.length], striped: i % 2 === 1 });
|
|
227
|
+
|
|
228
|
+
function pill(status, refusing) {
|
|
229
|
+
const pills = [{ text: status, quiet: status === 'ready' || status === 'running' }];
|
|
230
|
+
if (refusing) pills.push({ text: 'refusing', quiet: false });
|
|
231
|
+
return pills;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The memory meter: what the memory guard measures against its limit, in a segment per process,
|
|
236
|
+
* with ticks at the refuse and abort thresholds.
|
|
237
|
+
* @param {Object} m - used, limit, rejectAt, abortAt, rejectFraction, abortFraction, scope (the
|
|
238
|
+
* bar's label), segments ({label, bytes, swatch}), notes
|
|
239
|
+
*/
|
|
240
|
+
function meter({ used, limit, rejectAt, abortAt, rejectFraction, abortFraction, scope, segments, notes = [] }) {
|
|
241
|
+
if (!(limit > 0) || used === null || used === undefined) {
|
|
242
|
+
return { scope, unknown: true, notes };
|
|
243
|
+
}
|
|
244
|
+
const shown = segments.filter(s => s.bytes > 0);
|
|
245
|
+
const accounted = shown.reduce((a, s) => a + s.bytes, 0);
|
|
246
|
+
// what the processes' own figures don't account for (the measure is the cgroup's, or newer)
|
|
247
|
+
if (used - accounted > limit / 200) {
|
|
248
|
+
shown.push({ label: 'other', bytes: used - accounted, swatch: { color: NEUTRAL, striped: true } });
|
|
249
|
+
}
|
|
250
|
+
const scale = Math.max(used, accounted) > 0 ? used / Math.max(used, accounted) : 1;
|
|
251
|
+
return {
|
|
252
|
+
scope,
|
|
253
|
+
value: `${gb(used)} GB of ${gb(limit)} GB (${Math.round(pct(used, limit))}%)`,
|
|
254
|
+
segments: shown.map(s => ({ label: s.label, width: pct(s.bytes * scale, limit).toFixed(2), ...s.swatch,
|
|
255
|
+
title: `${s.label}: ${mb(s.bytes)} MB` })),
|
|
256
|
+
// at: the tick, from the left; right: where its label ends, from the right
|
|
257
|
+
ticks: [
|
|
258
|
+
{ label: `refuse ${Math.round(rejectFraction * 100)}% · ${gb(rejectAt)} GB`, at: pct(rejectAt, limit).toFixed(2), right: (100 - pct(rejectAt, limit)).toFixed(2) },
|
|
259
|
+
{ label: `abort ${Math.round(abortFraction * 100)}% · ${gb(abortAt)} GB`, at: pct(abortAt, limit).toFixed(2), right: (100 - pct(abortAt, limit)).toFixed(2) }
|
|
260
|
+
],
|
|
261
|
+
notes
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// the table's columns; the heap's limit goes in its header when every process has the same
|
|
266
|
+
const tableColumns = (heapLimit) => [
|
|
267
|
+
{ label: 'Process' }, { label: 'Status' }, { label: 'Uptime', num: true }, { label: 'Restarts', num: true },
|
|
268
|
+
{ label: 'Requests', num: true }, { label: 'Private MB', num: true }, { label: 'Mapped MB', num: true },
|
|
269
|
+
{ label: heapLimit ? `Heap MB of ${mb(heapLimit)}` : 'Heap MB used / limit', num: true },
|
|
270
|
+
{ label: 'Caches', num: true }, { label: 'Concepts', num: true }, { label: 'In flight', num: true }, { label: 'Age', num: true }
|
|
271
|
+
];
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The master's status panel under the launcher (tx/html/status-panel.liquid): a header, the
|
|
275
|
+
* cluster's tiles, the memory meter, a row per process, and footnotes. Requests are those each
|
|
276
|
+
* process served (its own count since it started), and the tile is their sum.
|
|
277
|
+
* @param {Object} snapshot - from buildSnapshot
|
|
278
|
+
* @param {Object} opts - now, uptime (the master's, text), uptimeSub, host ({used, total} bytes)
|
|
279
|
+
*/
|
|
280
|
+
function clusterView(snapshot, { now = Date.now(), uptime = '—', uptimeSub = null, host = null } = {}) {
|
|
281
|
+
const m = snapshot.memory;
|
|
282
|
+
const procs = snapshot.processes;
|
|
283
|
+
const fresh = procs.filter(p => p.fresh);
|
|
284
|
+
const requests = fresh.reduce((a, p) => a + (p.requests || 0), 0);
|
|
285
|
+
const longest = Math.max(0, ...procs.map(p => p.uptimeMs || 0));
|
|
286
|
+
const restarts = procs.reduce((a, p) => a + p.restarts, 0);
|
|
287
|
+
const c = snapshot.caches;
|
|
288
|
+
const tiles = [
|
|
289
|
+
{ label: 'Requests', value: n(requests), sub: longest > 60000 ? `${rate(requests / (longest / 60000))} /min avg` : null },
|
|
290
|
+
{ label: 'Client caches', value: dash(c.clientCaches, n), sub: c.clientConcepts === null ? null
|
|
291
|
+
: `${n(c.clientConcepts)} concepts${c.scope === 'shared' ? ' · shared' : ''}` },
|
|
292
|
+
{ label: 'Expansion cache', value: dash(c.expansionItems, n), unit: 'items' },
|
|
293
|
+
{ label: 'In flight', value: dash(snapshot.inFlight, n), sub: snapshot.refusing.length ? `refusing: ${snapshot.refusing.join(', ')}` : 'none refusing' },
|
|
294
|
+
{ label: 'Restarts', value: n(restarts), sub: 'since launch' },
|
|
295
|
+
{ label: 'Uptime', value: uptime, sub: uptimeSub }
|
|
296
|
+
];
|
|
297
|
+
const segments = procs.map((p, i) => ({ label: p.label, bytes: p.anon || 0, swatch: swatch(i) }));
|
|
298
|
+
segments.push({ label: 'Launcher', bytes: snapshot.launcher.anon || 0, swatch: { color: NEUTRAL, striped: false } });
|
|
299
|
+
const notes = host ? [`Host: ${gb(host.used)} of ${gb(host.total)} GB in use`] : [];
|
|
300
|
+
const memory = m.cgroup
|
|
301
|
+
? meter({ ...m, used: m.cgroup.anon, scope: 'cgroup private', segments, notes })
|
|
302
|
+
: meter({ ...m, used: m.processAnon, scope: 'processes\' private, summed', segments, notes });
|
|
303
|
+
const limits = new Set([...procs, snapshot.launcher].map(p => p.heapLimit).filter(Number.isFinite));
|
|
304
|
+
const heapLimit = limits.size === 1 ? [...limits][0] : null;
|
|
305
|
+
const heap = (p) => (p.heapUsed === null ? '—' : heapLimit ? mb(p.heapUsed) : `${mb(p.heapUsed)} / ${mb(p.heapLimit)}`);
|
|
306
|
+
const rows = procs.map((p, i) => ({
|
|
307
|
+
swatch: swatch(i),
|
|
308
|
+
cells: [{ text: p.label }, { pills: pill(p.fresh || p.figuresAgeMs === null ? p.status : 'stale', p.refusing === true) },
|
|
309
|
+
{ text: duration(p.uptimeMs), num: true }, { text: n(p.restarts), num: true }, { text: dash(p.requests, n), num: true },
|
|
310
|
+
{ text: dash(p.anon, mb), num: true }, { text: dash(p.file, mb), num: true },
|
|
311
|
+
{ text: heap(p), num: true },
|
|
312
|
+
{ text: dash(p.clientCaches, n), num: true }, { text: dash(p.clientConcepts, n), num: true },
|
|
313
|
+
{ text: dash(p.inFlight, n), num: true }, { text: ago(p.figuresAgeMs), num: true }]
|
|
314
|
+
}));
|
|
315
|
+
const l = snapshot.launcher;
|
|
316
|
+
rows.push({ swatch: { color: NEUTRAL, symbol: '' }, cells: [{ text: 'Launcher' }, { pills: pill('running') },
|
|
317
|
+
{ text: duration(l.uptimeMs), num: true }, { text: '—', num: true }, { text: '—', num: true },
|
|
318
|
+
{ text: dash(l.anon, mb), num: true }, { text: dash(l.file, mb), num: true },
|
|
319
|
+
{ text: heap(l), num: true },
|
|
320
|
+
{ text: '—', num: true }, { text: '—', num: true }, { text: '—', num: true }, { text: 'now', num: true }] });
|
|
321
|
+
const footnotes = [
|
|
322
|
+
'Mapped: terminology files a process has mapped; shared pages count in each process and are not in the memory above.',
|
|
323
|
+
...snapshot.missing.map(text => `Not shown: ${text}.`)
|
|
324
|
+
];
|
|
325
|
+
return {
|
|
326
|
+
heading: 'Cluster', meta: [`${procs.length} processes`, `updated ${ago(now - snapshot.sampledAt) === 'now' ? 'just now' : ago(now - snapshot.sampledAt) + ' ago'}`],
|
|
327
|
+
tiles, memory, table: { columns: tableColumns(heapLimit), rows }, footnotes
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The status panel of one process: a server with no role, a worker's own page, or a master
|
|
333
|
+
* before the launcher's first snapshot. Tiles and the meter, no per-process table.
|
|
334
|
+
* @param {Object} own - figures (ownFigures), uptime (text), uptimeSub, static (count), avgPerMin,
|
|
335
|
+
* guard ({measure, limit, rejectAt, abortAt, rejectFraction, abortFraction, group, fileBacked}),
|
|
336
|
+
* anon (this process's RssAnon, or null), host
|
|
337
|
+
* @param {Object} opts - heading, meta
|
|
338
|
+
*/
|
|
339
|
+
function processView(own, { heading = 'Server', meta = ['this process'] } = {}) {
|
|
340
|
+
const f = own.figures;
|
|
341
|
+
const g = own.guard;
|
|
342
|
+
const tiles = [
|
|
343
|
+
{ label: 'Requests', value: n(f.requests), sub: [Number.isFinite(own.avgPerMin) ? `${rate(own.avgPerMin)} /min avg` : null,
|
|
344
|
+
own.static ? `${n(own.static)} static` : null].filter(Boolean).join(' · ') || null },
|
|
345
|
+
{ label: 'Client caches', value: n(f.clientCaches), sub: `${n(f.clientConcepts)} concepts` },
|
|
346
|
+
{ label: 'Expansion cache', value: n(f.expansionItems), unit: 'items' },
|
|
347
|
+
{ label: 'In flight', value: dash(f.inFlight, n), sub: f.refusing ? 'refusing' : 'not refusing' },
|
|
348
|
+
{ label: 'Heap', value: mb(f.heapUsed), unit: 'MB', sub: `of ${mb(f.heapLimit)} MB` },
|
|
349
|
+
{ label: 'Uptime', value: own.uptime, sub: own.uptimeSub }
|
|
350
|
+
];
|
|
351
|
+
const scope = g.group ? 'cgroup private, every process' : g.fileBacked ? 'this process, RSS less mapped files' : 'this process, RSS';
|
|
352
|
+
const segments = [{ label: 'this process', bytes: g.group ? own.anon || 0 : g.measure, swatch: swatch(0) }];
|
|
353
|
+
const notes = own.host ? [`Host: ${gb(own.host.used)} of ${gb(own.host.total)} GB in use`] : [];
|
|
354
|
+
return {
|
|
355
|
+
heading, meta, tiles,
|
|
356
|
+
memory: meter({ ...g, used: g.measure, scope, segments, notes }),
|
|
357
|
+
table: null, footnotes: []
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The storage tiles below the graphs: closure tables, logs and disk, where there are any.
|
|
363
|
+
* @param {Object} s - heading, closure (closureStats() or null), logs ({files, bytes, kept}
|
|
364
|
+
* or null), disk ({free, total} bytes, or null)
|
|
365
|
+
*/
|
|
366
|
+
function storageView({ heading = 'Storage', closure = null, logs = null, disk = null }) {
|
|
367
|
+
const tiles = [];
|
|
368
|
+
if (closure) {
|
|
369
|
+
tiles.push({ label: 'Closure tables', value: n(closure.tables) });
|
|
370
|
+
tiles.push({ label: 'Closure entries', value: n(closure.entries) });
|
|
371
|
+
tiles.push({ label: 'Closure concepts', value: n(closure.concepts) });
|
|
372
|
+
const small = closure.bytes !== null && closure.bytes < 2 ** 20;
|
|
373
|
+
tiles.push({ label: 'Closure database', value: closure.bytes === null ? 'in memory' : small ? n(Math.ceil(closure.bytes / 1024)) : mb(closure.bytes),
|
|
374
|
+
unit: closure.bytes === null ? null : small ? 'KB' : 'MB', sub: closure.retentionDays ? `kept ${closure.retentionDays} days unused` : null });
|
|
375
|
+
}
|
|
376
|
+
if (logs) {
|
|
377
|
+
tiles.push({ label: 'Logs', value: mb(logs.bytes), unit: 'MB', sub: `${n(logs.files)} files · ${logs.kept} kept` });
|
|
378
|
+
}
|
|
379
|
+
if (disk) {
|
|
380
|
+
tiles.push({ label: 'Disk free', value: gb(disk.free), unit: 'GB', sub: `of ${gb(disk.total)} GB` });
|
|
381
|
+
}
|
|
382
|
+
// as many columns as tiles, up to six: the row is never short
|
|
383
|
+
return { heading, meta: [], tiles, columns: Math.min(6, Math.max(1, tiles.length)), memory: null, table: null, footnotes: [] };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// the memory guard's measure at this share of its refusal threshold or more is shown as a problem
|
|
387
|
+
const NEAR_REFUSING = 0.9;
|
|
388
|
+
// and less free disk than this share of it
|
|
389
|
+
const DISK_LOW = 0.1;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The problems the status line lists, each a sentence; none when all is well.
|
|
393
|
+
* @param {Object} p - refusing (labels), measure, rejectAt (the memory guard's), down
|
|
394
|
+
* ({label, status}: processes not running), stale ({label, ageMs}), restarts ({label, count}),
|
|
395
|
+
* disk ({free, total}|null), failedTasks ({name, state, date}, stats.failedTasks()), other
|
|
396
|
+
* (sentences of their own, such as npm audit's), now
|
|
397
|
+
*/
|
|
398
|
+
function problems({ refusing = [], measure = null, rejectAt = null, down = [], stale = [], restarts = [], disk = null, failedTasks = [], other = [], now = Date.now() }) {
|
|
399
|
+
const list = [];
|
|
400
|
+
const memory = Number.isFinite(measure) && rejectAt > 0 ? `${gb(measure)} GB, threshold ${gb(rejectAt)} GB` : null;
|
|
401
|
+
if (refusing.length) {
|
|
402
|
+
list.push(`Refusing requests: ${refusing.join(', ')}${memory ? ` (memory ${memory})` : ''}`);
|
|
403
|
+
} else if (memory && measure >= NEAR_REFUSING * rejectAt) {
|
|
404
|
+
list.push(`Memory near the refusal threshold: ${memory}`);
|
|
405
|
+
}
|
|
406
|
+
for (const p of down) list.push(`${p.label}: ${p.status}`);
|
|
407
|
+
for (const p of stale) list.push(`${p.label}: no figures for ${Math.round(p.ageMs / 1000)} s`);
|
|
408
|
+
for (const p of restarts) list.push(`${p.label} restarted ${n(p.count)} ${p.count === 1 ? 'time' : 'times'} since launch`);
|
|
409
|
+
if (disk && disk.total > 0 && disk.free < DISK_LOW * disk.total) {
|
|
410
|
+
list.push(`Disk low: ${gb(disk.free)} GB free of ${gb(disk.total)} GB`);
|
|
411
|
+
}
|
|
412
|
+
for (const t of failedTasks) list.push(`${t.name} failed${Number.isFinite(t.date) ? ` ${duration(now - t.date)} ago` : ''}: ${t.state}`);
|
|
413
|
+
return list.concat(other);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The status line at the top of / and /dashboard (tx/html/status-line.liquid) on the master
|
|
418
|
+
* under the launcher: the cluster's requests, the master's uptime, the heap of every process
|
|
419
|
+
* summed, and the problems. The rest is in clusterView, under "More".
|
|
420
|
+
* @param {Object} snapshot - from buildSnapshot
|
|
421
|
+
* @param {Object} opts - uptime, uptimeSub, disk, failedTasks, other, now
|
|
422
|
+
*/
|
|
423
|
+
function clusterSummary(snapshot, { uptime = '—', uptimeSub = null, disk = null, failedTasks = [], other = [], now = Date.now() } = {}) {
|
|
424
|
+
const m = snapshot.memory;
|
|
425
|
+
const procs = snapshot.processes;
|
|
426
|
+
const fresh = procs.filter(p => p.fresh);
|
|
427
|
+
const requests = fresh.reduce((a, p) => a + (p.requests || 0), 0);
|
|
428
|
+
const longest = Math.max(0, ...procs.map(p => p.uptimeMs || 0));
|
|
429
|
+
const heaps = [...fresh, snapshot.launcher].filter(p => Number.isFinite(p.heapUsed));
|
|
430
|
+
const label = (code) => (procs.find(p => p.code === code) || { label: code }).label;
|
|
431
|
+
return {
|
|
432
|
+
items: [
|
|
433
|
+
{ label: 'Requests', value: n(requests), sub: longest > 60000 ? `${rate(requests / (longest / 60000))} /min` : null },
|
|
434
|
+
{ label: 'Uptime', value: uptime, sub: uptimeSub },
|
|
435
|
+
{ label: 'Heap', value: heaps.length ? mb(heaps.reduce((a, p) => a + p.heapUsed, 0)) : '—', unit: 'MB',
|
|
436
|
+
sub: `${heaps.length} ${heaps.length === 1 ? 'process' : 'processes'}` }
|
|
437
|
+
],
|
|
438
|
+
problems: problems({
|
|
439
|
+
refusing: snapshot.refusing.map(label),
|
|
440
|
+
measure: m.cgroup ? m.cgroup.anon : m.processAnon, rejectAt: m.rejectAt,
|
|
441
|
+
down: procs.filter(p => p.status !== 'ready' && p.status !== 'running').map(p => ({ label: p.label, status: p.status })),
|
|
442
|
+
stale: procs.filter(p => !p.fresh && p.figuresAgeMs !== null && p.uptimeMs !== null).map(p => ({ label: p.label, ageMs: p.figuresAgeMs })),
|
|
443
|
+
restarts: procs.filter(p => p.restarts > 0).map(p => ({ label: p.label, count: p.restarts })),
|
|
444
|
+
disk, failedTasks, other, now
|
|
445
|
+
})
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The status line of one process (processView's figures): its requests, uptime and heap, and
|
|
451
|
+
* the problems.
|
|
452
|
+
* @param {Object} own - as for processView, with disk, failedTasks and other
|
|
453
|
+
*/
|
|
454
|
+
function processSummary(own, { now = Date.now() } = {}) {
|
|
455
|
+
const f = own.figures;
|
|
456
|
+
const g = own.guard;
|
|
457
|
+
return {
|
|
458
|
+
items: [
|
|
459
|
+
{ label: 'Requests', value: n(f.requests), sub: Number.isFinite(own.avgPerMin) ? `${rate(own.avgPerMin)} /min` : null },
|
|
460
|
+
{ label: 'Uptime', value: own.uptime, sub: own.uptimeSub },
|
|
461
|
+
{ label: 'Heap', value: mb(f.heapUsed), unit: 'MB', sub: `of ${mb(f.heapLimit)}` }
|
|
462
|
+
],
|
|
463
|
+
problems: problems({ refusing: f.refusing ? ['this process'] : [], measure: g.measure, rejectAt: g.rejectAt,
|
|
464
|
+
disk: own.disk || null, failedTasks: own.failedTasks || [], other: own.other || [], now })
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
module.exports = { ownFigures, connectStatus, buildSnapshot, clusterView, processView, storageView, problems, clusterSummary, processSummary, PALETTE, STATUS, CLUSTER_STATUS, STATUS_INTERVAL_MS, STALE_INTERVALS };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads config.json, which may have comments: // to the end of the line, and /* ... *\/ blocks,
|
|
3
|
+
* anywhere JSON allows white space. Otherwise it is plain JSON (no trailing commas, no single
|
|
4
|
+
* quotes).
|
|
5
|
+
*
|
|
6
|
+
* The comments are replaced by spaces (keeping their line breaks), so a JSON syntax error is
|
|
7
|
+
* reported at the line and column it has in the file.
|
|
8
|
+
*/
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The JSON text with its comments blanked out. Text in strings is left alone, so a URL like
|
|
13
|
+
* "http://example.org" is not a comment
|
|
14
|
+
* @param {string} text
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function stripComments(text) {
|
|
18
|
+
let out = '';
|
|
19
|
+
let i = 0;
|
|
20
|
+
const n = text.length;
|
|
21
|
+
while (i < n) {
|
|
22
|
+
const c = text[i];
|
|
23
|
+
if (c === '"') {
|
|
24
|
+
// a string: copy it as is, up to the closing quote that isn't escaped
|
|
25
|
+
let j = i + 1;
|
|
26
|
+
while (j < n && text[j] !== '"') {
|
|
27
|
+
if (text[j] === '\\') {
|
|
28
|
+
j++;
|
|
29
|
+
}
|
|
30
|
+
j++;
|
|
31
|
+
}
|
|
32
|
+
out += text.substring(i, j + 1);
|
|
33
|
+
i = j + 1;
|
|
34
|
+
} else if (c === '/' && text[i + 1] === '/') {
|
|
35
|
+
let j = i;
|
|
36
|
+
while (j < n && text[j] !== '\n' && text[j] !== '\r') {
|
|
37
|
+
j++;
|
|
38
|
+
}
|
|
39
|
+
out += ' '.repeat(j - i);
|
|
40
|
+
i = j;
|
|
41
|
+
} else if (c === '/' && text[i + 1] === '*') {
|
|
42
|
+
const end = text.indexOf('*/', i + 2);
|
|
43
|
+
if (end < 0) {
|
|
44
|
+
throw new Error(`unterminated /* comment at ${position(text, i)}`);
|
|
45
|
+
}
|
|
46
|
+
out += text.substring(i, end + 2).replace(/[^\r\n]/g, ' ');
|
|
47
|
+
i = end + 2;
|
|
48
|
+
} else {
|
|
49
|
+
out += c;
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** "line L, column C" (1-based) of an offset in the text */
|
|
57
|
+
function position(text, offset) {
|
|
58
|
+
const before = text.substring(0, offset);
|
|
59
|
+
const line = before.split('\n').length;
|
|
60
|
+
const column = offset - before.lastIndexOf('\n');
|
|
61
|
+
return `line ${line}, column ${column}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse config.json text (JSON with comments)
|
|
66
|
+
* @param {string} text
|
|
67
|
+
* @returns {Object}
|
|
68
|
+
* @throws {Error} with the line and column of a syntax error
|
|
69
|
+
*/
|
|
70
|
+
function parseConfig(text) {
|
|
71
|
+
if (text.charCodeAt(0) === 0xFEFF) {
|
|
72
|
+
text = text.substring(1); // a byte order mark, as some Windows editors write
|
|
73
|
+
}
|
|
74
|
+
const json = stripComments(text);
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(json);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// V8 says "... at position N" (sometimes with "(line L column C)"): give the line and column
|
|
79
|
+
const m = /at position (\d+)/.exec(e.message);
|
|
80
|
+
if (m && !/line \d+/.test(e.message)) {
|
|
81
|
+
throw new Error(`${e.message} (${position(json, Number(m[1]))})`);
|
|
82
|
+
}
|
|
83
|
+
throw e;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read and parse a config file (JSON with comments)
|
|
89
|
+
* @param {string} filePath
|
|
90
|
+
* @returns {Object}
|
|
91
|
+
*/
|
|
92
|
+
function readConfig(filePath) {
|
|
93
|
+
return parseConfig(fs.readFileSync(filePath, 'utf8'));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { readConfig, parseConfig, stripComments };
|