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.
@@ -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
- return function handleChange() {
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
- (async () => {
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
- handleSubscriptionChange();
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
+ }