signalk-edge-link 2.5.0 → 2.6.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/lib/config-watcher.js +40 -32
- package/lib/instance.js +87 -2
- package/lib/packet.js +20 -7
- package/lib/pipeline-v2-client.js +22 -0
- package/lib/pipeline-v2-server.js +37 -0
- package/lib/shared/connection-schema.js +9 -1
- package/lib/values-snapshot.js +152 -0
- package/package.json +1 -1
- package/public/{277.99e19dcb5b778c964ace.js → 277.d365356803e61762acb0.js} +3 -3
- package/public/277.d365356803e61762acb0.js.map +1 -0
- package/public/982.078efbd502a09820e418.js +2 -0
- package/public/982.078efbd502a09820e418.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/982.8b63f75cddc5341d56cb.js +0 -2
- package/public/982.8b63f75cddc5341d56cb.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;
|
|
@@ -110,6 +111,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
110
111
|
metaDiffFlushTimer: null,
|
|
111
112
|
metaSnapshotTimers: [],
|
|
112
113
|
lastMetaRequestAt: 0,
|
|
114
|
+
lastFullStatusRequestAt: 0,
|
|
113
115
|
sourceRegistry: (0, source_replication_1.createSourceRegistry)(app)
|
|
114
116
|
};
|
|
115
117
|
const metricsApi = (0, metrics_1.default)();
|
|
@@ -383,6 +385,22 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
383
385
|
app.debug(`[${instanceId}] META_REQUEST snapshot failed: ${msg}`);
|
|
384
386
|
});
|
|
385
387
|
}
|
|
388
|
+
/** Minimum gap between server-initiated full-status replays. Prevents a
|
|
389
|
+
* restarting or misconfigured server from flooding the link. */
|
|
390
|
+
const FULL_STATUS_REQUEST_RATE_LIMIT_MS = 10000;
|
|
391
|
+
/** Server asked for a full values snapshot (FULL_STATUS_REQUEST control
|
|
392
|
+
* packet). Replays the entire current Signal K tree to the server.
|
|
393
|
+
* Rate-limited to prevent replay floods across rapid server restarts. */
|
|
394
|
+
function handleFullStatusRequest() {
|
|
395
|
+
const now = Date.now();
|
|
396
|
+
if (now - state.lastFullStatusRequestAt < FULL_STATUS_REQUEST_RATE_LIMIT_MS) {
|
|
397
|
+
app.debug(`[${instanceId}] FULL_STATUS_REQUEST rate-limited, skipping`);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
state.lastFullStatusRequestAt = now;
|
|
401
|
+
app.debug(`[${instanceId}] FULL_STATUS_REQUEST received — replaying values snapshot`);
|
|
402
|
+
replayValuesSnapshot("full-status-request");
|
|
403
|
+
}
|
|
386
404
|
async function sendSourceSnapshot() {
|
|
387
405
|
if (state.stopped ||
|
|
388
406
|
!state.readyToSend ||
|
|
@@ -404,6 +422,48 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
404
422
|
app.debug(`[${instanceId}] source snapshot send failed: ${msg}`);
|
|
405
423
|
}
|
|
406
424
|
}
|
|
425
|
+
/**
|
|
426
|
+
* Replay every value currently in the local Signal K tree by feeding
|
|
427
|
+
* synthetic deltas through `processDelta`. The subscription manager only
|
|
428
|
+
* delivers *future* deltas, so values published into the tree before
|
|
429
|
+
* `subscribe()` ran (one-shot startup deltas, or deltas published by a
|
|
430
|
+
* co-located edge-link server-mode instance via `app.handleMessage`) would
|
|
431
|
+
* otherwise never reach the receiver. Triggered on initial subscribe
|
|
432
|
+
* success, on subscribe-retry success, and on UDP socket recovery so the
|
|
433
|
+
* receiver gets re-primed if it restarted.
|
|
434
|
+
*
|
|
435
|
+
* Returns silently if the SignalK app object doesn't expose `signalk`
|
|
436
|
+
* (older signalk-server versions or test mocks), or while the instance is
|
|
437
|
+
* not yet ready to send.
|
|
438
|
+
*/
|
|
439
|
+
function replayValuesSnapshot(reason) {
|
|
440
|
+
if (state.stopped || !state.readyToSend || !state.processDelta) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
let snapshot;
|
|
444
|
+
try {
|
|
445
|
+
snapshot = (0, values_snapshot_1.collectValuesSnapshot)(appProxy);
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
449
|
+
app.debug(`[${instanceId}] values snapshot collect failed (${reason}): ${msg}`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (snapshot.length === 0) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
app.debug(`[${instanceId}] Replaying ${snapshot.length} value-snapshot delta(s) (${reason})`);
|
|
456
|
+
for (const delta of snapshot) {
|
|
457
|
+
try {
|
|
458
|
+
state.processDelta(delta);
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
462
|
+
app.debug(`[${instanceId}] values snapshot replay failed (${reason}): ${msg}`);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
407
467
|
function restartSourceSnapshotTimer() {
|
|
408
468
|
clearInterval(state.sourceSnapshotTimer ?? undefined);
|
|
409
469
|
state.sourceSnapshotTimer = null;
|
|
@@ -598,6 +658,9 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
598
658
|
}
|
|
599
659
|
state.readyToSend = true;
|
|
600
660
|
_setStatus("Subscription restored", true);
|
|
661
|
+
// Replay current tree state so any value that arrived in the tree
|
|
662
|
+
// while we were retrying isn't permanently lost.
|
|
663
|
+
replayValuesSnapshot("subscription retry");
|
|
601
664
|
}
|
|
602
665
|
catch (retryError) {
|
|
603
666
|
const msg = retryError instanceof Error ? retryError.message : String(retryError);
|
|
@@ -649,6 +712,12 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
649
712
|
if (state.metaConfig?.enabled) {
|
|
650
713
|
scheduleMetadataSnapshot(2000);
|
|
651
714
|
}
|
|
715
|
+
// Replay every value already present in the tree. Without this,
|
|
716
|
+
// one-shot startup deltas published before subscribe() ran (e.g. by
|
|
717
|
+
// a co-located edge-link server-mode instance) never reach the
|
|
718
|
+
// receiver, since the subscription manager only delivers future
|
|
719
|
+
// events.
|
|
720
|
+
replayValuesSnapshot("initial subscribe");
|
|
652
721
|
}
|
|
653
722
|
catch (subscribeError) {
|
|
654
723
|
// Re-subscribe failed — restore old handlers so stop() can still
|
|
@@ -713,8 +782,16 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
713
782
|
}
|
|
714
783
|
];
|
|
715
784
|
state.configWatcherObjects = watcherConfigs.map((cfg) => (0, config_watcher_1.createWatcherWithRecovery)({ ...cfg, instanceId, app, state }));
|
|
716
|
-
// Trigger initial subscription load
|
|
717
|
-
|
|
785
|
+
// Trigger initial subscription load immediately (no debounce). The
|
|
786
|
+
// debounce delay exists to coalesce file-system change events; for the
|
|
787
|
+
// one-shot startup load it just widens the window during which deltas
|
|
788
|
+
// produced by co-located plugins are emitted before our subscription
|
|
789
|
+
// is registered with the subscriptionmanager — those deltas would be
|
|
790
|
+
// silently dropped since the manager only delivers future events.
|
|
791
|
+
handleSubscriptionChange.flush().catch((err) => {
|
|
792
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
793
|
+
app.error(`[${instanceId}] Initial subscription load failed: ${msg}`);
|
|
794
|
+
});
|
|
718
795
|
app.debug(`[${instanceId}] Configuration file watchers initialized`);
|
|
719
796
|
}
|
|
720
797
|
catch (err) {
|
|
@@ -1001,6 +1078,10 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1001
1078
|
if (state.metaConfig?.enabled) {
|
|
1002
1079
|
scheduleMetadataSnapshot(1000);
|
|
1003
1080
|
}
|
|
1081
|
+
// Re-prime the receiver's value tree too — a restarted
|
|
1082
|
+
// receiver lost everything we sent before, and the
|
|
1083
|
+
// subscription manager won't replay past deltas.
|
|
1084
|
+
replayValuesSnapshot("socket recovery");
|
|
1004
1085
|
}
|
|
1005
1086
|
catch (recoveryErr) {
|
|
1006
1087
|
state.socketRecoveryInProgress = false;
|
|
@@ -1064,6 +1145,9 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1064
1145
|
if (typeof v2Pipeline.setMetaRequestHandler === "function") {
|
|
1065
1146
|
v2Pipeline.setMetaRequestHandler(handleMetaRequest);
|
|
1066
1147
|
}
|
|
1148
|
+
if (typeof v2Pipeline.setFullStatusRequestHandler === "function") {
|
|
1149
|
+
v2Pipeline.setFullStatusRequestHandler(handleFullStatusRequest);
|
|
1150
|
+
}
|
|
1067
1151
|
v2Pipeline.startMetricsPublishing();
|
|
1068
1152
|
if (options.congestionControl && options.congestionControl.enabled) {
|
|
1069
1153
|
v2Pipeline.startCongestionControl();
|
|
@@ -1146,6 +1230,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1146
1230
|
Object.keys(state.configContentHashes).forEach((k) => delete state.configContentHashes[k]);
|
|
1147
1231
|
state.excludedSentences = ["GSV"];
|
|
1148
1232
|
state.lastPacketTime = 0;
|
|
1233
|
+
state.lastFullStatusRequestAt = 0;
|
|
1149
1234
|
// Reset metrics
|
|
1150
1235
|
resetMetrics();
|
|
1151
1236
|
// Clear timers
|
package/lib/packet.js
CHANGED
|
@@ -55,7 +55,9 @@ const PacketType = Object.freeze({
|
|
|
55
55
|
HEARTBEAT: 0x04,
|
|
56
56
|
HELLO: 0x05,
|
|
57
57
|
METADATA: 0x06,
|
|
58
|
-
META_REQUEST: 0x07
|
|
58
|
+
META_REQUEST: 0x07,
|
|
59
|
+
/** Server → client: request a full values snapshot replay. */
|
|
60
|
+
FULL_STATUS_REQUEST: 0x08
|
|
59
61
|
});
|
|
60
62
|
exports.PacketType = PacketType;
|
|
61
63
|
/**
|
|
@@ -176,6 +178,14 @@ class PacketBuilder {
|
|
|
176
178
|
buildMetaRequestPacket(options = {}) {
|
|
177
179
|
return this._buildPacket(PacketType.META_REQUEST, Buffer.alloc(0), {}, options);
|
|
178
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Build a FULL_STATUS_REQUEST control packet (server → client).
|
|
183
|
+
* Payload is empty. Instructs the client to replay its full values snapshot
|
|
184
|
+
* so the server can rebuild state after a restart.
|
|
185
|
+
*/
|
|
186
|
+
buildFullStatusRequestPacket(options = {}) {
|
|
187
|
+
return this._buildPacket(PacketType.FULL_STATUS_REQUEST, Buffer.alloc(0), {}, options);
|
|
188
|
+
}
|
|
179
189
|
/**
|
|
180
190
|
* Build an ACK packet
|
|
181
191
|
* @param {number} ackedSequence - Sequence number being acknowledged
|
|
@@ -404,11 +414,13 @@ class PacketParser {
|
|
|
404
414
|
payload = payloadData;
|
|
405
415
|
}
|
|
406
416
|
else {
|
|
407
|
-
// HEARTBEAT and
|
|
408
|
-
// — accept as-is. ACK / NAK / HELLO must include
|
|
409
|
-
// reject undersized payloads so forged control
|
|
410
|
-
// through unverified.
|
|
411
|
-
if (type !== PacketType.HEARTBEAT &&
|
|
417
|
+
// HEARTBEAT, META_REQUEST, and FULL_STATUS_REQUEST carry a 0-byte
|
|
418
|
+
// payload with no CRC — accept as-is. ACK / NAK / HELLO must include
|
|
419
|
+
// a 2-byte CRC16 trailer; reject undersized payloads so forged control
|
|
420
|
+
// frames cannot slip through unverified.
|
|
421
|
+
if (type !== PacketType.HEARTBEAT &&
|
|
422
|
+
type !== PacketType.META_REQUEST &&
|
|
423
|
+
type !== PacketType.FULL_STATUS_REQUEST) {
|
|
412
424
|
if (payload.length < 2) {
|
|
413
425
|
throw new Error(`Control packet payload too short for CRC: ${payload.length} byte(s)`);
|
|
414
426
|
}
|
|
@@ -504,7 +516,8 @@ function getTypeName(type) {
|
|
|
504
516
|
[PacketType.HEARTBEAT]: "HEARTBEAT",
|
|
505
517
|
[PacketType.HELLO]: "HELLO",
|
|
506
518
|
[PacketType.METADATA]: "METADATA",
|
|
507
|
-
[PacketType.META_REQUEST]: "META_REQUEST"
|
|
519
|
+
[PacketType.META_REQUEST]: "META_REQUEST",
|
|
520
|
+
[PacketType.FULL_STATUS_REQUEST]: "FULL_STATUS_REQUEST"
|
|
508
521
|
};
|
|
509
522
|
return names[type] || "UNKNOWN";
|
|
510
523
|
}
|
|
@@ -106,6 +106,9 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
106
106
|
// (META_REQUEST control packet). Wired up by instance.ts, which is the only
|
|
107
107
|
// layer that knows how to build a snapshot from `app.signalk.retrieve()`.
|
|
108
108
|
let metaRequestHandler = null;
|
|
109
|
+
// Callback fired when the server sends FULL_STATUS_REQUEST, asking the client
|
|
110
|
+
// to replay its complete current values snapshot.
|
|
111
|
+
let fullStatusRequestHandler = null;
|
|
109
112
|
let metaEnvelopeSeq = 0;
|
|
110
113
|
let sourceEnvelopeSeq = 0;
|
|
111
114
|
// Seed all four meta bandwidth counters so downstream consumers (metrics
|
|
@@ -632,6 +635,9 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
632
635
|
function setMetaRequestHandler(handler) {
|
|
633
636
|
metaRequestHandler = handler;
|
|
634
637
|
}
|
|
638
|
+
function setFullStatusRequestHandler(handler) {
|
|
639
|
+
fullStatusRequestHandler = handler;
|
|
640
|
+
}
|
|
635
641
|
/**
|
|
636
642
|
* Handle incoming ACK packet from server.
|
|
637
643
|
* Removes acknowledged packets from the retransmit queue.
|
|
@@ -772,6 +778,21 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
772
778
|
}
|
|
773
779
|
}
|
|
774
780
|
}
|
|
781
|
+
else if (parsed.type === packet_1.PacketType.FULL_STATUS_REQUEST) {
|
|
782
|
+
// Server asks us to replay our full values snapshot (e.g. after a
|
|
783
|
+
// server restart). Rate-limited in instance.ts to prevent abuse.
|
|
784
|
+
if (fullStatusRequestHandler) {
|
|
785
|
+
try {
|
|
786
|
+
Promise.resolve(fullStatusRequestHandler()).catch((err) => {
|
|
787
|
+
app.debug(`FULL_STATUS_REQUEST handler rejected: ${err instanceof Error ? err.message : String(err)}`);
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
catch (err) {
|
|
791
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
792
|
+
app.debug(`FULL_STATUS_REQUEST handler error: ${errMsg}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
775
796
|
// Ignore other packet types on client side
|
|
776
797
|
}
|
|
777
798
|
catch (err) {
|
|
@@ -1110,6 +1131,7 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
1110
1131
|
sendMetadata,
|
|
1111
1132
|
sendSourceSnapshot,
|
|
1112
1133
|
setMetaRequestHandler,
|
|
1134
|
+
setFullStatusRequestHandler,
|
|
1113
1135
|
getPacketBuilder,
|
|
1114
1136
|
getRetransmitQueue,
|
|
1115
1137
|
getMetricsPublisher,
|
|
@@ -178,6 +178,7 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
178
178
|
rateLimitCount: 0,
|
|
179
179
|
rateLimitWindowStart: Date.now(),
|
|
180
180
|
metaRequested: false,
|
|
181
|
+
statusRequested: false,
|
|
181
182
|
lastMetaEnvSeq: null,
|
|
182
183
|
seenMetaChunkIdx: new Set(),
|
|
183
184
|
lastSourceEnvSeq: null,
|
|
@@ -212,6 +213,8 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
212
213
|
rateLimitWindowStart: Date.now(),
|
|
213
214
|
// META_REQUEST bookkeeping
|
|
214
215
|
metaRequested: false,
|
|
216
|
+
// FULL_STATUS_REQUEST bookkeeping
|
|
217
|
+
statusRequested: false,
|
|
215
218
|
// Stale-envelope rejection for METADATA packets
|
|
216
219
|
lastMetaEnvSeq: null,
|
|
217
220
|
seenMetaChunkIdx: new Set(),
|
|
@@ -491,6 +494,7 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
491
494
|
seenChunkIdx.clear();
|
|
492
495
|
if (!isSource) {
|
|
493
496
|
session.metaRequested = false;
|
|
497
|
+
session.statusRequested = false;
|
|
494
498
|
}
|
|
495
499
|
}
|
|
496
500
|
if (lastEnvSeq !== null) {
|
|
@@ -610,6 +614,7 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
610
614
|
session.lastMetaEnvSeq = null;
|
|
611
615
|
session.seenMetaChunkIdx.clear();
|
|
612
616
|
session.metaRequested = false;
|
|
617
|
+
session.statusRequested = false;
|
|
613
618
|
}
|
|
614
619
|
if (session.lastMetaEnvSeq !== null) {
|
|
615
620
|
const distance = (envSeq - session.lastMetaEnvSeq) >>> 0;
|
|
@@ -697,6 +702,21 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
697
702
|
recordError("general", `v2 META decode error: ${msg}`);
|
|
698
703
|
}
|
|
699
704
|
}
|
|
705
|
+
/**
|
|
706
|
+
* Build and send a FULL_STATUS_REQUEST (0x08) control packet to a client.
|
|
707
|
+
* Instructs the client to replay its complete current values snapshot so the
|
|
708
|
+
* server can rebuild state immediately after a restart.
|
|
709
|
+
*/
|
|
710
|
+
async function _sendFullStatusRequest(session, secretKey) {
|
|
711
|
+
try {
|
|
712
|
+
const packet = packetBuilder.buildFullStatusRequestPacket({ secretKey });
|
|
713
|
+
await _sendUDP(packet, { address: session.address, port: session.port });
|
|
714
|
+
app.debug(`[v2-server] FULL_STATUS_REQUEST sent to ${session.key}`);
|
|
715
|
+
}
|
|
716
|
+
catch (err) {
|
|
717
|
+
throw err;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
700
720
|
/**
|
|
701
721
|
* Build and send a META_REQUEST (0x07) control packet to a client.
|
|
702
722
|
* Instructs the client to emit a fresh metadata snapshot — used on first
|
|
@@ -819,6 +839,15 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
819
839
|
app.debug(`[v2-server] META_REQUEST send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
820
840
|
});
|
|
821
841
|
}
|
|
842
|
+
// If the operator enabled full-status-on-restart, also request a values
|
|
843
|
+
// snapshot. Capped at one per session so rapid HELLOs (e.g. NAT churn)
|
|
844
|
+
// don't create repeated replay bursts.
|
|
845
|
+
if (session && !session.statusRequested && state.options?.requestFullStatusOnRestart) {
|
|
846
|
+
session.statusRequested = true;
|
|
847
|
+
_sendFullStatusRequest(session, secretKey).catch((err) => {
|
|
848
|
+
app.debug(`[v2-server] FULL_STATUS_REQUEST send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
849
|
+
});
|
|
850
|
+
}
|
|
822
851
|
return;
|
|
823
852
|
}
|
|
824
853
|
if (parsed.type === packet_1.PacketType.METADATA) {
|
|
@@ -892,6 +921,14 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
892
921
|
if (session) {
|
|
893
922
|
session.hasReceivedData = true;
|
|
894
923
|
}
|
|
924
|
+
// On first DATA from a new session, request full-status replay if enabled.
|
|
925
|
+
// This covers the case where the client sends data before its next HELLO.
|
|
926
|
+
if (session && !session.statusRequested && state.options?.requestFullStatusOnRestart) {
|
|
927
|
+
session.statusRequested = true;
|
|
928
|
+
_sendFullStatusRequest(session, secretKey).catch((err) => {
|
|
929
|
+
app.debug(`[v2-server] FULL_STATUS_REQUEST (data trigger) send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
895
932
|
const dataSeq = parsed.sequence >>> 0;
|
|
896
933
|
if (session) {
|
|
897
934
|
if (seqResult.resynced || session.lossBaseSeq === null) {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* results to `RJSFSchema` at call sites.
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
-
exports.alertThresholdsProperty = exports.skipOwnDataProperty = exports.enableNotificationsProperty = exports.bondingProperty = exports.congestionControlProperty = exports.serverReliabilityProperty = exports.clientReliabilityProperty = exports.clientTransportProperties = exports.v1ClientPingProperties = exports.commonConnectionProperties = void 0;
|
|
18
|
+
exports.alertThresholdsProperty = exports.skipOwnDataProperty = exports.enableNotificationsProperty = exports.bondingProperty = exports.congestionControlProperty = exports.serverReliabilityProperty = exports.requestFullStatusOnRestartProperty = exports.clientReliabilityProperty = exports.clientTransportProperties = exports.v1ClientPingProperties = exports.commonConnectionProperties = void 0;
|
|
19
19
|
exports.buildConnectionItemSchema = buildConnectionItemSchema;
|
|
20
20
|
exports.buildWebappConnectionSchema = buildWebappConnectionSchema;
|
|
21
21
|
const crypto_constants_1 = require("./crypto-constants");
|
|
@@ -246,6 +246,12 @@ exports.clientReliabilityProperty = {
|
|
|
246
246
|
}
|
|
247
247
|
};
|
|
248
248
|
// ── v2/v3 reliability (server pipeline — ACK/NAK timing) ──────────────────────
|
|
249
|
+
exports.requestFullStatusOnRestartProperty = {
|
|
250
|
+
type: "boolean",
|
|
251
|
+
title: "Request Full Status on Server Start (v2/v3 only)",
|
|
252
|
+
description: "When enabled, the server sends a request to each client on first contact asking it to replay its complete current values snapshot. This rebuilds the server's state immediately after a restart instead of waiting for incremental deltas to arrive.",
|
|
253
|
+
default: false
|
|
254
|
+
};
|
|
249
255
|
exports.serverReliabilityProperty = {
|
|
250
256
|
type: "object",
|
|
251
257
|
title: "Reliability Settings (v2/v3 only)",
|
|
@@ -511,6 +517,7 @@ function buildConnectionItemSchema() {
|
|
|
511
517
|
{
|
|
512
518
|
properties: {
|
|
513
519
|
serverType: { enum: ["server"] },
|
|
520
|
+
requestFullStatusOnRestart: exports.requestFullStatusOnRestartProperty,
|
|
514
521
|
reliability: exports.serverReliabilityProperty
|
|
515
522
|
}
|
|
516
523
|
},
|
|
@@ -567,6 +574,7 @@ function buildWebappConnectionSchema(isClient, protocolVersion) {
|
|
|
567
574
|
}
|
|
568
575
|
}
|
|
569
576
|
else if (isReliableProtocol) {
|
|
577
|
+
props.requestFullStatusOnRestart = exports.requestFullStatusOnRestartProperty;
|
|
570
578
|
props.reliability = exports.serverReliabilityProperty;
|
|
571
579
|
}
|
|
572
580
|
return { type: "object", required, properties: props };
|
|
@@ -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
|
+
}
|