signalk-edge-link 2.5.0 → 2.5.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.
- package/lib/config-watcher.js +40 -32
- package/lib/instance.js +66 -2
- package/lib/values-snapshot.js +152 -0
- package/package.json +165 -165
- package/public/{277.99e19dcb5b778c964ace.js → 277.d365356803e61762acb0.js} +3 -3
- package/public/277.d365356803e61762acb0.js.map +1 -0
- package/public/remoteEntry.js +1 -1
- package/public/remoteEntry.js.map +1 -1
- package/public/277.99e19dcb5b778c964ace.js.map +0 -1
- /package/public/{277.99e19dcb5b778c964ace.js.LICENSE.txt → 277.d365356803e61762acb0.js.LICENSE.txt} +0 -0
package/lib/config-watcher.js
CHANGED
|
@@ -51,42 +51,37 @@ const crypto = __importStar(require("crypto"));
|
|
|
51
51
|
const config_io_1 = require("./config-io");
|
|
52
52
|
const constants_1 = require("./constants");
|
|
53
53
|
const { readFile, writeFile, mkdir } = fs_1.promises;
|
|
54
|
-
/**
|
|
55
|
-
* Create a debounced config-change handler.
|
|
56
|
-
*/
|
|
57
54
|
function createDebouncedConfigHandler(opts) {
|
|
58
55
|
const { name, getFilePath, processConfig, state, instanceId, app, readFallback } = opts;
|
|
59
|
-
|
|
56
|
+
async function runLoad() {
|
|
57
|
+
if (state.stopped)
|
|
58
|
+
return;
|
|
59
|
+
let content;
|
|
60
|
+
const filePath = getFilePath();
|
|
61
|
+
if (readFallback !== undefined) {
|
|
62
|
+
content = filePath ? await readFile(filePath, "utf-8").catch(() => null) : null;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
content = filePath ? await readFile(filePath, "utf-8") : null;
|
|
66
|
+
}
|
|
67
|
+
if (state.stopped)
|
|
68
|
+
return;
|
|
69
|
+
const hashSource = content || JSON.stringify(readFallback) || "";
|
|
70
|
+
const contentHash = crypto.createHash(constants_1.CONTENT_HASH_ALGORITHM).update(hashSource).digest("hex");
|
|
71
|
+
if (contentHash === state.configContentHashes[name]) {
|
|
72
|
+
app.debug(`[${instanceId}] ${name} file unchanged, skipping`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const parsed = content ? JSON.parse(content) : readFallback;
|
|
76
|
+
await processConfig(parsed);
|
|
77
|
+
if (!state.stopped) {
|
|
78
|
+
state.configContentHashes[name] = contentHash;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const handleChange = function () {
|
|
60
82
|
clearTimeout(state.configDebounceTimers[name]);
|
|
61
83
|
state.configDebounceTimers[name] = setTimeout(() => {
|
|
62
|
-
(
|
|
63
|
-
if (state.stopped)
|
|
64
|
-
return;
|
|
65
|
-
let content;
|
|
66
|
-
const filePath = getFilePath();
|
|
67
|
-
if (readFallback !== undefined) {
|
|
68
|
-
content = filePath ? await readFile(filePath, "utf-8").catch(() => null) : null;
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
content = filePath ? await readFile(filePath, "utf-8") : null;
|
|
72
|
-
}
|
|
73
|
-
if (state.stopped)
|
|
74
|
-
return;
|
|
75
|
-
const hashSource = content || JSON.stringify(readFallback) || "";
|
|
76
|
-
const contentHash = crypto
|
|
77
|
-
.createHash(constants_1.CONTENT_HASH_ALGORITHM)
|
|
78
|
-
.update(hashSource)
|
|
79
|
-
.digest("hex");
|
|
80
|
-
if (contentHash === state.configContentHashes[name]) {
|
|
81
|
-
app.debug(`[${instanceId}] ${name} file unchanged, skipping`);
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
const parsed = content ? JSON.parse(content) : readFallback;
|
|
85
|
-
await processConfig(parsed);
|
|
86
|
-
if (!state.stopped) {
|
|
87
|
-
state.configContentHashes[name] = contentHash;
|
|
88
|
-
}
|
|
89
|
-
})().catch((err) => {
|
|
84
|
+
runLoad().catch((err) => {
|
|
90
85
|
if (state.stopped)
|
|
91
86
|
return;
|
|
92
87
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -94,6 +89,19 @@ function createDebouncedConfigHandler(opts) {
|
|
|
94
89
|
});
|
|
95
90
|
}, constants_1.FILE_WATCH_DEBOUNCE_DELAY);
|
|
96
91
|
};
|
|
92
|
+
handleChange.flush = async function flush() {
|
|
93
|
+
clearTimeout(state.configDebounceTimers[name]);
|
|
94
|
+
try {
|
|
95
|
+
await runLoad();
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (state.stopped)
|
|
99
|
+
return;
|
|
100
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
101
|
+
app.error(`[${instanceId}] Error handling ${name} change: ${msg}`);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
return handleChange;
|
|
97
105
|
}
|
|
98
106
|
/**
|
|
99
107
|
* Create a file-system watcher with automatic recovery on error or rename.
|
package/lib/instance.js
CHANGED
|
@@ -34,6 +34,7 @@ const config_watcher_1 = require("./config-watcher");
|
|
|
34
34
|
const metadata_1 = require("./metadata");
|
|
35
35
|
const delta_sanitizer_1 = require("./delta-sanitizer");
|
|
36
36
|
const source_snapshot_1 = require("./source-snapshot");
|
|
37
|
+
const values_snapshot_1 = require("./values-snapshot");
|
|
37
38
|
const DELTA_SEND_MAX_RETRIES = 1;
|
|
38
39
|
const DELTA_SEND_RETRY_BACKOFF_MS = 100;
|
|
39
40
|
const SOURCE_SNAPSHOT_INTERVAL_MS = 60000;
|
|
@@ -404,6 +405,48 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
404
405
|
app.debug(`[${instanceId}] source snapshot send failed: ${msg}`);
|
|
405
406
|
}
|
|
406
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Replay every value currently in the local Signal K tree by feeding
|
|
410
|
+
* synthetic deltas through `processDelta`. The subscription manager only
|
|
411
|
+
* delivers *future* deltas, so values published into the tree before
|
|
412
|
+
* `subscribe()` ran (one-shot startup deltas, or deltas published by a
|
|
413
|
+
* co-located edge-link server-mode instance via `app.handleMessage`) would
|
|
414
|
+
* otherwise never reach the receiver. Triggered on initial subscribe
|
|
415
|
+
* success, on subscribe-retry success, and on UDP socket recovery so the
|
|
416
|
+
* receiver gets re-primed if it restarted.
|
|
417
|
+
*
|
|
418
|
+
* Returns silently if the SignalK app object doesn't expose `signalk`
|
|
419
|
+
* (older signalk-server versions or test mocks), or while the instance is
|
|
420
|
+
* not yet ready to send.
|
|
421
|
+
*/
|
|
422
|
+
function replayValuesSnapshot(reason) {
|
|
423
|
+
if (state.stopped || !state.readyToSend || !state.processDelta) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
let snapshot;
|
|
427
|
+
try {
|
|
428
|
+
snapshot = (0, values_snapshot_1.collectValuesSnapshot)(appProxy);
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
432
|
+
app.debug(`[${instanceId}] values snapshot collect failed (${reason}): ${msg}`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (snapshot.length === 0) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
app.debug(`[${instanceId}] Replaying ${snapshot.length} value-snapshot delta(s) (${reason})`);
|
|
439
|
+
for (const delta of snapshot) {
|
|
440
|
+
try {
|
|
441
|
+
state.processDelta(delta);
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
445
|
+
app.debug(`[${instanceId}] values snapshot replay failed (${reason}): ${msg}`);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
407
450
|
function restartSourceSnapshotTimer() {
|
|
408
451
|
clearInterval(state.sourceSnapshotTimer ?? undefined);
|
|
409
452
|
state.sourceSnapshotTimer = null;
|
|
@@ -598,6 +641,9 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
598
641
|
}
|
|
599
642
|
state.readyToSend = true;
|
|
600
643
|
_setStatus("Subscription restored", true);
|
|
644
|
+
// Replay current tree state so any value that arrived in the tree
|
|
645
|
+
// while we were retrying isn't permanently lost.
|
|
646
|
+
replayValuesSnapshot("subscription retry");
|
|
601
647
|
}
|
|
602
648
|
catch (retryError) {
|
|
603
649
|
const msg = retryError instanceof Error ? retryError.message : String(retryError);
|
|
@@ -649,6 +695,12 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
649
695
|
if (state.metaConfig?.enabled) {
|
|
650
696
|
scheduleMetadataSnapshot(2000);
|
|
651
697
|
}
|
|
698
|
+
// Replay every value already present in the tree. Without this,
|
|
699
|
+
// one-shot startup deltas published before subscribe() ran (e.g. by
|
|
700
|
+
// a co-located edge-link server-mode instance) never reach the
|
|
701
|
+
// receiver, since the subscription manager only delivers future
|
|
702
|
+
// events.
|
|
703
|
+
replayValuesSnapshot("initial subscribe");
|
|
652
704
|
}
|
|
653
705
|
catch (subscribeError) {
|
|
654
706
|
// Re-subscribe failed — restore old handlers so stop() can still
|
|
@@ -713,8 +765,16 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
713
765
|
}
|
|
714
766
|
];
|
|
715
767
|
state.configWatcherObjects = watcherConfigs.map((cfg) => (0, config_watcher_1.createWatcherWithRecovery)({ ...cfg, instanceId, app, state }));
|
|
716
|
-
// Trigger initial subscription load
|
|
717
|
-
|
|
768
|
+
// Trigger initial subscription load immediately (no debounce). The
|
|
769
|
+
// debounce delay exists to coalesce file-system change events; for the
|
|
770
|
+
// one-shot startup load it just widens the window during which deltas
|
|
771
|
+
// produced by co-located plugins are emitted before our subscription
|
|
772
|
+
// is registered with the subscriptionmanager — those deltas would be
|
|
773
|
+
// silently dropped since the manager only delivers future events.
|
|
774
|
+
handleSubscriptionChange.flush().catch((err) => {
|
|
775
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
776
|
+
app.error(`[${instanceId}] Initial subscription load failed: ${msg}`);
|
|
777
|
+
});
|
|
718
778
|
app.debug(`[${instanceId}] Configuration file watchers initialized`);
|
|
719
779
|
}
|
|
720
780
|
catch (err) {
|
|
@@ -1001,6 +1061,10 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1001
1061
|
if (state.metaConfig?.enabled) {
|
|
1002
1062
|
scheduleMetadataSnapshot(1000);
|
|
1003
1063
|
}
|
|
1064
|
+
// Re-prime the receiver's value tree too — a restarted
|
|
1065
|
+
// receiver lost everything we sent before, and the
|
|
1066
|
+
// subscription manager won't replay past deltas.
|
|
1067
|
+
replayValuesSnapshot("socket recovery");
|
|
1004
1068
|
}
|
|
1005
1069
|
catch (recoveryErr) {
|
|
1006
1070
|
state.socketRecoveryInProgress = false;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.collectValuesSnapshot = collectValuesSnapshot;
|
|
4
|
+
// Reserved leaf-level keys we must not descend into when walking the tree.
|
|
5
|
+
const SK_LEAF_KEYS = new Set([
|
|
6
|
+
"value",
|
|
7
|
+
"values",
|
|
8
|
+
"timestamp",
|
|
9
|
+
"$source",
|
|
10
|
+
"meta",
|
|
11
|
+
"sentence",
|
|
12
|
+
"pgn"
|
|
13
|
+
]);
|
|
14
|
+
// Top-level tree keys that aren't context groups.
|
|
15
|
+
const SK_NON_CONTEXT_KEYS = new Set(["self", "version", "sources"]);
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function readLeafFromNode(obj) {
|
|
20
|
+
// A Signal K value leaf has either a `value` property with sibling
|
|
21
|
+
// `timestamp`, or a multi-source `values` map. Single-source leaves are the
|
|
22
|
+
// common case so we handle them first.
|
|
23
|
+
if ("value" in obj && typeof obj.timestamp === "string") {
|
|
24
|
+
return {
|
|
25
|
+
value: obj.value,
|
|
26
|
+
timestamp: obj.timestamp,
|
|
27
|
+
source: typeof obj.$source === "string" ? obj.$source : undefined
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function walkValues(node, pathParts, onLeaf) {
|
|
33
|
+
if (!isRecord(node)) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// Multi-source case wins when present: `values` is { sourceLabel:
|
|
37
|
+
// { value, timestamp } } and is more authoritative than the top-level
|
|
38
|
+
// `value`/`timestamp` (which mirror the latest of the multi-source map).
|
|
39
|
+
// Emit one leaf per source so the receiver retains attribution, then stop
|
|
40
|
+
// — the rest of the node is per-source bookkeeping.
|
|
41
|
+
if (isRecord(node.values)) {
|
|
42
|
+
for (const [sourceLabel, sourceData] of Object.entries(node.values)) {
|
|
43
|
+
if (isRecord(sourceData) &&
|
|
44
|
+
"value" in sourceData &&
|
|
45
|
+
typeof sourceData.timestamp === "string") {
|
|
46
|
+
onLeaf({
|
|
47
|
+
path: pathParts.join("."),
|
|
48
|
+
value: sourceData.value,
|
|
49
|
+
timestamp: sourceData.timestamp,
|
|
50
|
+
source: sourceLabel
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// Single-source leaf.
|
|
57
|
+
const single = readLeafFromNode(node);
|
|
58
|
+
if (single !== null) {
|
|
59
|
+
onLeaf({
|
|
60
|
+
path: pathParts.join("."),
|
|
61
|
+
value: single.value,
|
|
62
|
+
timestamp: single.timestamp,
|
|
63
|
+
source: single.source
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Container — descend.
|
|
68
|
+
for (const key of Object.keys(node)) {
|
|
69
|
+
if (SK_LEAF_KEYS.has(key)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
walkValues(node[key], pathParts.concat(key), onLeaf);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build synthetic deltas for every value currently in the Signal K tree.
|
|
77
|
+
*
|
|
78
|
+
* Returns one delta per `(context, source)` pair, with all matching leaves
|
|
79
|
+
* grouped into a single `updates[].values[]` array. `DeltaUpdate.timestamp`
|
|
80
|
+
* is per-update (not per-leaf), so the latest timestamp across the group is
|
|
81
|
+
* used — receivers treat the delta as "current state" anyway.
|
|
82
|
+
*
|
|
83
|
+
* Returns [] when `app.signalk` isn't exposed (older signalk-server) or the
|
|
84
|
+
* tree is empty.
|
|
85
|
+
*/
|
|
86
|
+
function collectValuesSnapshot(app) {
|
|
87
|
+
if (!app.signalk || typeof app.signalk.retrieve !== "function") {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
let tree;
|
|
91
|
+
try {
|
|
92
|
+
const retrieved = app.signalk.retrieve();
|
|
93
|
+
if (!isRecord(retrieved)) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
tree = retrieved;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
// Group leaves by (context, source) so we emit one delta per group.
|
|
102
|
+
const grouped = new Map();
|
|
103
|
+
for (const contextGroup of Object.keys(tree)) {
|
|
104
|
+
if (SK_NON_CONTEXT_KEYS.has(contextGroup)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const group = tree[contextGroup];
|
|
108
|
+
if (!isRecord(group)) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
for (const contextId of Object.keys(group)) {
|
|
112
|
+
const contextNode = group[contextId];
|
|
113
|
+
if (!isRecord(contextNode)) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const context = `${contextGroup}.${contextId}`;
|
|
117
|
+
walkValues(contextNode, [], (leaf) => {
|
|
118
|
+
const key = `${context}|${leaf.source ?? ""}`;
|
|
119
|
+
const existing = grouped.get(key);
|
|
120
|
+
if (existing) {
|
|
121
|
+
existing.values.push({ path: leaf.path, value: leaf.value });
|
|
122
|
+
if (leaf.timestamp > existing.timestamp) {
|
|
123
|
+
existing.timestamp = leaf.timestamp;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
grouped.set(key, {
|
|
128
|
+
context,
|
|
129
|
+
source: leaf.source,
|
|
130
|
+
timestamp: leaf.timestamp,
|
|
131
|
+
values: [{ path: leaf.path, value: leaf.value }]
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const deltas = [];
|
|
138
|
+
for (const entry of grouped.values()) {
|
|
139
|
+
if (entry.values.length === 0) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const update = {
|
|
143
|
+
timestamp: entry.timestamp,
|
|
144
|
+
values: entry.values
|
|
145
|
+
};
|
|
146
|
+
if (entry.source) {
|
|
147
|
+
update.$source = entry.source;
|
|
148
|
+
}
|
|
149
|
+
deltas.push({ context: entry.context, updates: [update] });
|
|
150
|
+
}
|
|
151
|
+
return deltas;
|
|
152
|
+
}
|