signalk-edge-link 2.3.0 → 2.4.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/connection-config.js +8 -0
- package/lib/delta-sanitizer.js +85 -0
- package/lib/instance.js +68 -3
- package/lib/metadata.js +46 -2
- package/lib/pipeline-v2-client.js +174 -29
- package/lib/pipeline-v2-server.js +125 -8
- package/lib/pipeline.js +3 -1
- package/lib/routes/metrics.js +25 -1
- package/lib/routes.js +6 -0
- package/lib/shared/connection-schema.js +10 -1
- package/lib/source-dispatch.js +98 -0
- package/lib/source-replication.js +241 -0
- package/lib/source-snapshot.js +68 -0
- package/package.json +1 -1
- package/public/982.cc4f5aca99be921e0171.js +2 -0
- package/public/982.cc4f5aca99be921e0171.js.map +1 -0
- package/public/index.html +1 -1
- package/public/main.0b6f5e3267731da945f0.js.map +1 -1
- package/public/{main.e2b9c98749816ac2e285.css → main.2ae3dd54effad689f0da.css} +16 -1
- package/public/main.2ae3dd54effad689f0da.css.map +1 -0
- package/public/remoteEntry.js +1 -1
- package/public/remoteEntry.js.map +1 -1
- package/public/982.63949a2b2f6c5854e034.js +0 -2
- package/public/982.63949a2b2f6c5854e034.js.map +0 -1
- package/public/main.e2b9c98749816ac2e285.css.map +0 -1
- package/public/main.js +0 -467
package/lib/connection-config.js
CHANGED
|
@@ -18,6 +18,7 @@ exports.VALID_CONNECTION_KEYS = [
|
|
|
18
18
|
"useMsgpack",
|
|
19
19
|
"usePathDictionary",
|
|
20
20
|
"enableNotifications",
|
|
21
|
+
"skipOwnData",
|
|
21
22
|
"protocolVersion",
|
|
22
23
|
"udpAddress",
|
|
23
24
|
"helloMessageSender",
|
|
@@ -75,6 +76,9 @@ function validateConnectionConfig(connection, prefix = "") {
|
|
|
75
76
|
if (conn.alertThresholds !== undefined) {
|
|
76
77
|
return `${p}alertThresholds is not supported in server mode`;
|
|
77
78
|
}
|
|
79
|
+
if (conn.skipOwnData !== undefined) {
|
|
80
|
+
return `${p}skipOwnData is not supported in server mode`;
|
|
81
|
+
}
|
|
78
82
|
}
|
|
79
83
|
if (!isValidPort(conn.udpPort, 1024)) {
|
|
80
84
|
return `${p}udpPort must be an integer between 1024 and 65535`;
|
|
@@ -103,6 +107,9 @@ function validateConnectionConfig(connection, prefix = "") {
|
|
|
103
107
|
if (conn.enableNotifications !== undefined && typeof conn.enableNotifications !== "boolean") {
|
|
104
108
|
return `${p}enableNotifications must be a boolean`;
|
|
105
109
|
}
|
|
110
|
+
if (conn.skipOwnData !== undefined && typeof conn.skipOwnData !== "boolean") {
|
|
111
|
+
return `${p}skipOwnData must be a boolean`;
|
|
112
|
+
}
|
|
106
113
|
if (conn.name !== undefined &&
|
|
107
114
|
(typeof conn.name !== "string" || conn.name.length > 40)) {
|
|
108
115
|
return `${p}name must be a string of at most 40 characters`;
|
|
@@ -328,6 +335,7 @@ function sanitizeConnectionConfig(connection) {
|
|
|
328
335
|
delete out.congestionControl;
|
|
329
336
|
delete out.bonding;
|
|
330
337
|
delete out.alertThresholds;
|
|
338
|
+
delete out.skipOwnData;
|
|
331
339
|
}
|
|
332
340
|
return out;
|
|
333
341
|
}
|
package/lib/delta-sanitizer.js
CHANGED
|
@@ -1,7 +1,92 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.stripOwnDataFromDelta = stripOwnDataFromDelta;
|
|
3
4
|
exports.sanitizeDeltaForSignalK = sanitizeDeltaForSignalK;
|
|
4
5
|
exports.sanitizeDeltaPayloadForSignalK = sanitizeDeltaPayloadForSignalK;
|
|
6
|
+
/**
|
|
7
|
+
* Path prefixes for data this plugin publishes locally. When the
|
|
8
|
+
* `skipOwnData` option is set on a client connection, value entries with
|
|
9
|
+
* matching paths are stripped before the delta is forwarded over the link so
|
|
10
|
+
* the receiver's Signal K tree is not polluted with the sender's own
|
|
11
|
+
* edge-link metrics. The `networking.edgeLink.*` subtree is owned entirely
|
|
12
|
+
* by this plugin so the whole prefix is matched.
|
|
13
|
+
*/
|
|
14
|
+
const OWN_DATA_PATH_PREFIXES = ["networking.edgeLink."];
|
|
15
|
+
/**
|
|
16
|
+
* RTT paths the plugin publishes — kept by `stripOwnDataFromDelta` even when
|
|
17
|
+
* `skipOwnData` is on, because operators rely on RTT for link-health
|
|
18
|
+
* visibility on both sides of the link. Covers v1 modem RTT
|
|
19
|
+
* (`networking.modem.rtt`, `networking.modem.<instanceId>.rtt`) and v2
|
|
20
|
+
* edge-link RTT (`networking.edgeLink.rtt`,
|
|
21
|
+
* `networking.edgeLink.<instanceId>.rtt`).
|
|
22
|
+
*/
|
|
23
|
+
const RTT_PATH_RE = /^networking\.(?:modem|edgeLink)(?:\.[^.]+)?\.rtt$/;
|
|
24
|
+
function isOwnDataPath(path) {
|
|
25
|
+
if (typeof path !== "string") {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
// RTT paths (modem + edgeLink, namespaced or not) are always forwarded so
|
|
29
|
+
// the receiver retains link-health visibility regardless of skipOwnData.
|
|
30
|
+
if (RTT_PATH_RE.test(path)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
for (const prefix of OWN_DATA_PATH_PREFIXES) {
|
|
34
|
+
// prefix.slice(0, -1) drops the trailing ".", so a published path that
|
|
35
|
+
// matches the prefix root exactly (e.g. just "networking.edgeLink") still
|
|
36
|
+
// counts as own data; startsWith(prefix) covers everything underneath.
|
|
37
|
+
if (path === prefix.slice(0, -1) || path.startsWith(prefix)) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Drop value/meta entries whose paths are owned by this plugin. Returns null
|
|
45
|
+
* when nothing remains to forward. Updates that become empty are dropped; the
|
|
46
|
+
* delta is dropped entirely when no updates survive.
|
|
47
|
+
*/
|
|
48
|
+
function stripOwnDataFromDelta(delta) {
|
|
49
|
+
if (!delta || !Array.isArray(delta.updates)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
let changed = false;
|
|
53
|
+
const surviving = [];
|
|
54
|
+
for (const update of delta.updates) {
|
|
55
|
+
const rawValues = Array.isArray(update.values) ? update.values : [];
|
|
56
|
+
const values = rawValues.filter((v) => !isOwnDataPath(v?.path));
|
|
57
|
+
const valuesChanged = values.length !== rawValues.length;
|
|
58
|
+
const rawMeta = Array.isArray(update.meta) ? update.meta : null;
|
|
59
|
+
const meta = rawMeta
|
|
60
|
+
? rawMeta.filter((m) => !isOwnDataPath(m?.path))
|
|
61
|
+
: null;
|
|
62
|
+
const metaChanged = rawMeta !== null && meta !== null && meta.length !== rawMeta.length;
|
|
63
|
+
if (values.length === 0 && (!meta || meta.length === 0)) {
|
|
64
|
+
changed = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (valuesChanged || metaChanged) {
|
|
68
|
+
changed = true;
|
|
69
|
+
const next = { ...update, values };
|
|
70
|
+
if (meta && meta.length > 0) {
|
|
71
|
+
next.meta = meta;
|
|
72
|
+
}
|
|
73
|
+
else if (rawMeta) {
|
|
74
|
+
delete next.meta;
|
|
75
|
+
}
|
|
76
|
+
surviving.push(next);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
surviving.push(update);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (surviving.length === 0) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!changed) {
|
|
86
|
+
return delta;
|
|
87
|
+
}
|
|
88
|
+
return { ...delta, updates: surviving };
|
|
89
|
+
}
|
|
5
90
|
function isObject(value) {
|
|
6
91
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
7
92
|
}
|
package/lib/instance.js
CHANGED
|
@@ -22,6 +22,7 @@ const dgram_1 = __importDefault(require("dgram"));
|
|
|
22
22
|
const crypto_1 = require("./crypto");
|
|
23
23
|
const ping_monitor_1 = __importDefault(require("ping-monitor"));
|
|
24
24
|
const metrics_1 = __importDefault(require("./metrics"));
|
|
25
|
+
const source_replication_1 = require("./source-replication");
|
|
25
26
|
const pipeline_1 = __importDefault(require("./pipeline"));
|
|
26
27
|
const pipeline_v2_client_1 = require("./pipeline-v2-client");
|
|
27
28
|
const pipeline_v2_server_1 = require("./pipeline-v2-server");
|
|
@@ -32,8 +33,10 @@ const config_io_1 = require("./config-io");
|
|
|
32
33
|
const config_watcher_1 = require("./config-watcher");
|
|
33
34
|
const metadata_1 = require("./metadata");
|
|
34
35
|
const delta_sanitizer_1 = require("./delta-sanitizer");
|
|
36
|
+
const source_snapshot_1 = require("./source-snapshot");
|
|
35
37
|
const DELTA_SEND_MAX_RETRIES = 1;
|
|
36
38
|
const DELTA_SEND_RETRY_BACKOFF_MS = 100;
|
|
39
|
+
const SOURCE_SNAPSHOT_INTERVAL_MS = 60000;
|
|
37
40
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
38
41
|
/**
|
|
39
42
|
* Derive a URL-safe identifier from a human-readable name.
|
|
@@ -102,10 +105,12 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
102
105
|
processDelta: null,
|
|
103
106
|
metaConfig: null,
|
|
104
107
|
metaTimer: null,
|
|
108
|
+
sourceSnapshotTimer: null,
|
|
105
109
|
metaDiffBuffer: [],
|
|
106
110
|
metaDiffFlushTimer: null,
|
|
107
111
|
metaSnapshotTimers: [],
|
|
108
|
-
lastMetaRequestAt: 0
|
|
112
|
+
lastMetaRequestAt: 0,
|
|
113
|
+
sourceRegistry: (0, source_replication_1.createSourceRegistry)(app)
|
|
109
114
|
};
|
|
110
115
|
const metricsApi = (0, metrics_1.default)();
|
|
111
116
|
const { metrics, recordError, resetMetrics } = metricsApi;
|
|
@@ -205,10 +210,25 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
205
210
|
});
|
|
206
211
|
/**
|
|
207
212
|
* Forward subscribed deltas as-is except for malformed value entries that
|
|
208
|
-
* Signal K would reject on the receiver side.
|
|
213
|
+
* Signal K would reject on the receiver side. When `skipOwnData` is set on
|
|
214
|
+
* a client connection, also drop value/meta entries this plugin publishes
|
|
215
|
+
* locally under the `networking.edgeLink.*` subtree, so the receiver's
|
|
216
|
+
* Signal K tree is not polluted with the sender's own edge-link metrics.
|
|
217
|
+
*
|
|
218
|
+
* Exception: RTT paths are always forwarded regardless of skipOwnData so
|
|
219
|
+
* the operator retains link-health visibility on both sides of the link.
|
|
220
|
+
* The carve-out covers both v2 edge-link RTT
|
|
221
|
+
* (`networking.edgeLink.rtt`, `networking.edgeLink.<instanceId>.rtt`) and
|
|
222
|
+
* the v1 modem RTT paths historically published by `publishRtt`
|
|
223
|
+
* (`networking.modem.rtt`, `networking.modem.<instanceId>.rtt`). See
|
|
224
|
+
* `stripOwnDataFromDelta` in `delta-sanitizer.ts` for the implementation.
|
|
209
225
|
*/
|
|
210
226
|
function filterOutboundDelta(delta) {
|
|
211
|
-
|
|
227
|
+
const sanitized = (0, delta_sanitizer_1.sanitizeDeltaForSignalK)(delta);
|
|
228
|
+
if (!sanitized || !options.skipOwnData) {
|
|
229
|
+
return sanitized;
|
|
230
|
+
}
|
|
231
|
+
return (0, delta_sanitizer_1.stripOwnDataFromDelta)(sanitized);
|
|
212
232
|
}
|
|
213
233
|
// ── Metadata streaming ────────────────────────────────────────────────────
|
|
214
234
|
/** In-memory cache of last-sent meta (hashed) per context+path. Used to
|
|
@@ -363,6 +383,40 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
363
383
|
app.debug(`[${instanceId}] META_REQUEST snapshot failed: ${msg}`);
|
|
364
384
|
});
|
|
365
385
|
}
|
|
386
|
+
async function sendSourceSnapshot() {
|
|
387
|
+
if (state.stopped ||
|
|
388
|
+
!state.readyToSend ||
|
|
389
|
+
!state.pipeline ||
|
|
390
|
+
typeof state.pipeline.sendSourceSnapshot !== "function" ||
|
|
391
|
+
!options.secretKey ||
|
|
392
|
+
!options.udpAddress) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const sources = (0, source_snapshot_1.collectSourceSnapshot)(appProxy);
|
|
396
|
+
if (!sources || Object.keys(sources).length === 0) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
await state.pipeline.sendSourceSnapshot(sources, options.secretKey, options.udpAddress, options.udpPort);
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
404
|
+
app.debug(`[${instanceId}] source snapshot send failed: ${msg}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function restartSourceSnapshotTimer() {
|
|
408
|
+
clearInterval(state.sourceSnapshotTimer ?? undefined);
|
|
409
|
+
state.sourceSnapshotTimer = null;
|
|
410
|
+
if ((options.protocolVersion ?? 0) < 2) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
state.sourceSnapshotTimer = setInterval(() => {
|
|
414
|
+
sendSourceSnapshot().catch((err) => {
|
|
415
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
416
|
+
app.debug(`[${instanceId}] periodic source snapshot failed: ${msg}`);
|
|
417
|
+
});
|
|
418
|
+
}, SOURCE_SNAPSHOT_INTERVAL_MS);
|
|
419
|
+
}
|
|
366
420
|
/** Thin wrapper around the parser in `metadata.ts` so the instance log
|
|
367
421
|
* line is tagged with this connection's instanceId. Errors from the
|
|
368
422
|
* shared parser already have the `[meta-config]` prefix. */
|
|
@@ -935,6 +989,10 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
935
989
|
state.readyToSend = true;
|
|
936
990
|
_setStatus("UDP socket recovered", true);
|
|
937
991
|
app.debug(`[${instanceId}] UDP socket recovered`);
|
|
992
|
+
sendSourceSnapshot().catch((err) => {
|
|
993
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
994
|
+
app.debug(`[${instanceId}] recovery source snapshot failed: ${msg}`);
|
|
995
|
+
});
|
|
938
996
|
// A socket-level recovery is the strongest local signal that the
|
|
939
997
|
// remote receiver may have restarted. Re-prime its meta cache
|
|
940
998
|
// with a full snapshot so it doesn't have to wait a full
|
|
@@ -1012,6 +1070,11 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1012
1070
|
state.heartbeatHandle = v2Pipeline.startHeartbeat(options.udpAddress ?? "", options.udpPort, {
|
|
1013
1071
|
heartbeatInterval: options.heartbeatInterval
|
|
1014
1072
|
});
|
|
1073
|
+
restartSourceSnapshotTimer();
|
|
1074
|
+
sendSourceSnapshot().catch((err) => {
|
|
1075
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1076
|
+
app.debug(`[${instanceId}] initial source snapshot failed: ${msg}`);
|
|
1077
|
+
});
|
|
1015
1078
|
state.socketUdp.on("message", (msg, rinfo) => {
|
|
1016
1079
|
v2Pipeline.handleControlPacket(msg, rinfo).catch((err) => {
|
|
1017
1080
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -1089,6 +1152,8 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1089
1152
|
state.helloMessageSender = null;
|
|
1090
1153
|
clearInterval(state.metaTimer ?? undefined);
|
|
1091
1154
|
state.metaTimer = null;
|
|
1155
|
+
clearInterval(state.sourceSnapshotTimer ?? undefined);
|
|
1156
|
+
state.sourceSnapshotTimer = null;
|
|
1092
1157
|
clearTimeout(state.metaDiffFlushTimer ?? undefined);
|
|
1093
1158
|
state.metaDiffFlushTimer = null;
|
|
1094
1159
|
for (const handle of state.metaSnapshotTimers) {
|
package/lib/metadata.js
CHANGED
|
@@ -49,6 +49,37 @@ function stableStringify(value) {
|
|
|
49
49
|
function hashMeta(meta) {
|
|
50
50
|
return (0, crypto_1.createHash)("sha1").update(stableStringify(meta)).digest("hex");
|
|
51
51
|
}
|
|
52
|
+
const STRIP_UNSET = Symbol("strip-unset");
|
|
53
|
+
/**
|
|
54
|
+
* Deep-clone a metadata payload while removing unset placeholders.
|
|
55
|
+
*
|
|
56
|
+
* Explicit `null` values are preserved so metadata clear operations
|
|
57
|
+
* (`{ someField: null }`) can propagate to receivers.
|
|
58
|
+
*
|
|
59
|
+
* Returns a private sentinel when no useful data remains.
|
|
60
|
+
*/
|
|
61
|
+
function stripUnsetDeep(value) {
|
|
62
|
+
if (value === undefined) {
|
|
63
|
+
return STRIP_UNSET;
|
|
64
|
+
}
|
|
65
|
+
if (value === null || typeof value !== "object") {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
const cleaned = value
|
|
70
|
+
.map((item) => stripUnsetDeep(item))
|
|
71
|
+
.filter((item) => item !== STRIP_UNSET);
|
|
72
|
+
return cleaned.length > 0 ? cleaned : STRIP_UNSET;
|
|
73
|
+
}
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [k, v] of Object.entries(value)) {
|
|
76
|
+
const cleaned = stripUnsetDeep(v);
|
|
77
|
+
if (cleaned !== STRIP_UNSET) {
|
|
78
|
+
out[k] = cleaned;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return Object.keys(out).length > 0 ? out : STRIP_UNSET;
|
|
82
|
+
}
|
|
52
83
|
/**
|
|
53
84
|
* Cache of the last-sent meta value (hash) per `context+path` pair.
|
|
54
85
|
*
|
|
@@ -137,7 +168,13 @@ function walkMeta(node, pathParts, onMeta) {
|
|
|
137
168
|
}
|
|
138
169
|
const obj = node;
|
|
139
170
|
if (obj.meta && typeof obj.meta === "object" && !Array.isArray(obj.meta)) {
|
|
140
|
-
|
|
171
|
+
const cleanedMeta = stripUnsetDeep(obj.meta);
|
|
172
|
+
if (cleanedMeta !== STRIP_UNSET &&
|
|
173
|
+
cleanedMeta &&
|
|
174
|
+
typeof cleanedMeta === "object" &&
|
|
175
|
+
!Array.isArray(cleanedMeta)) {
|
|
176
|
+
onMeta(pathParts.join("."), cleanedMeta);
|
|
177
|
+
}
|
|
141
178
|
}
|
|
142
179
|
for (const key of Object.keys(obj)) {
|
|
143
180
|
// Signal K "value", "timestamp", "$source" are leaves, not sub-paths.
|
|
@@ -377,10 +414,17 @@ function extractLiveMeta(delta, config, selfContext) {
|
|
|
377
414
|
else {
|
|
378
415
|
context = rawContext;
|
|
379
416
|
}
|
|
417
|
+
const cleanedMeta = stripUnsetDeep(m.value);
|
|
418
|
+
if (cleanedMeta === STRIP_UNSET ||
|
|
419
|
+
!cleanedMeta ||
|
|
420
|
+
typeof cleanedMeta !== "object" ||
|
|
421
|
+
Array.isArray(cleanedMeta)) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
380
424
|
out.push({
|
|
381
425
|
context,
|
|
382
426
|
path: m.path,
|
|
383
|
-
meta:
|
|
427
|
+
meta: cleanedMeta
|
|
384
428
|
});
|
|
385
429
|
}
|
|
386
430
|
}
|
|
@@ -107,6 +107,7 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
107
107
|
// layer that knows how to build a snapshot from `app.signalk.retrieve()`.
|
|
108
108
|
let metaRequestHandler = null;
|
|
109
109
|
let metaEnvelopeSeq = 0;
|
|
110
|
+
let sourceEnvelopeSeq = 0;
|
|
110
111
|
// Seed all four meta bandwidth counters so downstream consumers (metrics
|
|
111
112
|
// publishers, prometheus exporter, tests) always see numeric zeros rather
|
|
112
113
|
// than undefined on a fresh pipeline. Uses || 0 at write sites elsewhere as
|
|
@@ -398,6 +399,128 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
398
399
|
throw error;
|
|
399
400
|
}
|
|
400
401
|
}
|
|
402
|
+
function isSourceRecord(value) {
|
|
403
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
404
|
+
}
|
|
405
|
+
function mergeSourcePatch(target, patch) {
|
|
406
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
407
|
+
const current = target[key];
|
|
408
|
+
if (isSourceRecord(current) && isSourceRecord(value)) {
|
|
409
|
+
mergeSourcePatch(current, value);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
target[key] = value;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function buildSourcePatch(path, value) {
|
|
417
|
+
let patch = value;
|
|
418
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
419
|
+
patch = { [path[i]]: patch };
|
|
420
|
+
}
|
|
421
|
+
return patch;
|
|
422
|
+
}
|
|
423
|
+
function flattenSourcePatches(value, path = []) {
|
|
424
|
+
if (!isSourceRecord(value)) {
|
|
425
|
+
return path.length > 0 ? [buildSourcePatch(path, value)] : [];
|
|
426
|
+
}
|
|
427
|
+
const entries = Object.entries(value);
|
|
428
|
+
if (path.length > 0 && entries.length === 0) {
|
|
429
|
+
return [buildSourcePatch(path, {})];
|
|
430
|
+
}
|
|
431
|
+
const patches = [];
|
|
432
|
+
for (const [key, entry] of entries) {
|
|
433
|
+
patches.push(...flattenSourcePatches(entry, path.concat(key)));
|
|
434
|
+
}
|
|
435
|
+
return patches;
|
|
436
|
+
}
|
|
437
|
+
function buildSourceChunk(patches) {
|
|
438
|
+
const out = {};
|
|
439
|
+
for (const patch of patches) {
|
|
440
|
+
mergeSourcePatch(out, patch);
|
|
441
|
+
}
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
async function buildSourceSnapshotPacket(sources, envelopeSeq, idx, total, useMsgpack, secretKey) {
|
|
445
|
+
const envelope = {
|
|
446
|
+
v: 1,
|
|
447
|
+
kind: "sources",
|
|
448
|
+
seq: envelopeSeq >>> 0,
|
|
449
|
+
idx,
|
|
450
|
+
total,
|
|
451
|
+
sources
|
|
452
|
+
};
|
|
453
|
+
const serialized = (0, pipeline_utils_1.deltaBuffer)(envelope, useMsgpack);
|
|
454
|
+
const compressed = await (0, pipeline_utils_1.compressPayload)(serialized, useMsgpack);
|
|
455
|
+
const encrypted = (0, crypto_1.encryptBinary)(compressed, secretKey, { stretchAsciiKey });
|
|
456
|
+
return packetBuilder.buildMetadataPacket(encrypted, {
|
|
457
|
+
compressed: true,
|
|
458
|
+
encrypted: true,
|
|
459
|
+
messagepack: useMsgpack,
|
|
460
|
+
pathDictionary: false
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
async function chunkSourceSnapshot(sources, envelopeSeq, useMsgpack, secretKey) {
|
|
464
|
+
const sourcePatches = flattenSourcePatches(sources);
|
|
465
|
+
const chunks = [];
|
|
466
|
+
let currentPatches = [];
|
|
467
|
+
for (const patch of sourcePatches) {
|
|
468
|
+
const candidatePatches = currentPatches.concat([patch]);
|
|
469
|
+
const candidate = buildSourceChunk(candidatePatches);
|
|
470
|
+
const candidatePacket = await buildSourceSnapshotPacket(candidate, envelopeSeq, sourcePatches.length, sourcePatches.length, useMsgpack, secretKey);
|
|
471
|
+
if (currentPatches.length > 0 && candidatePacket.length > constants_1.MAX_SAFE_UDP_PAYLOAD) {
|
|
472
|
+
chunks.push(currentPatches);
|
|
473
|
+
currentPatches = [patch];
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
currentPatches = candidatePatches;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (currentPatches.length > 0) {
|
|
480
|
+
chunks.push(currentPatches);
|
|
481
|
+
}
|
|
482
|
+
let finalChunks = chunks;
|
|
483
|
+
while (true) {
|
|
484
|
+
const packets = [];
|
|
485
|
+
let splitIndex = -1;
|
|
486
|
+
for (let i = 0; i < finalChunks.length; i++) {
|
|
487
|
+
const patchChunk = finalChunks[i];
|
|
488
|
+
const sourceChunk = buildSourceChunk(patchChunk);
|
|
489
|
+
const packet = await buildSourceSnapshotPacket(sourceChunk, envelopeSeq, i, finalChunks.length, useMsgpack, secretKey);
|
|
490
|
+
packets.push({ sources: sourceChunk, packet });
|
|
491
|
+
if (packet.length > constants_1.MAX_SAFE_UDP_PAYLOAD && patchChunk.length > 1) {
|
|
492
|
+
splitIndex = i;
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (splitIndex === -1) {
|
|
497
|
+
return packets;
|
|
498
|
+
}
|
|
499
|
+
const patchesToSplit = finalChunks[splitIndex];
|
|
500
|
+
const midpoint = Math.ceil(patchesToSplit.length / 2);
|
|
501
|
+
finalChunks = [
|
|
502
|
+
...finalChunks.slice(0, splitIndex),
|
|
503
|
+
patchesToSplit.slice(0, midpoint),
|
|
504
|
+
patchesToSplit.slice(midpoint),
|
|
505
|
+
...finalChunks.slice(splitIndex + 1)
|
|
506
|
+
];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function recordSentMetadataPacket(packet, udpAddress, udpPort) {
|
|
510
|
+
if (monitoringHooks) {
|
|
511
|
+
const rinfo = { address: udpAddress, port: udpPort };
|
|
512
|
+
if (monitoringHooks.packetCapture) {
|
|
513
|
+
monitoringHooks.packetCapture.capture(packet, "send", rinfo);
|
|
514
|
+
}
|
|
515
|
+
if (monitoringHooks.packetInspector) {
|
|
516
|
+
monitoringHooks.packetInspector.inspect(packet, "send", rinfo);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
metrics.bandwidth.metaBytesOut = (metrics.bandwidth.metaBytesOut || 0) + packet.length;
|
|
520
|
+
metrics.bandwidth.metaPacketsOut = (metrics.bandwidth.metaPacketsOut || 0) + 1;
|
|
521
|
+
metrics.bandwidth.bytesOut += packet.length;
|
|
522
|
+
metrics.bandwidth.packetsOut++;
|
|
523
|
+
}
|
|
401
524
|
/**
|
|
402
525
|
* Send a batch of Signal K metadata entries to the receiver as one or more
|
|
403
526
|
* METADATA (0x06) packets. Mirrors the compress → encrypt → packet-build
|
|
@@ -446,19 +569,7 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
446
569
|
metrics.smartBatching.oversizedPackets++;
|
|
447
570
|
}
|
|
448
571
|
await udpSendAsync(packet, udpAddress, udpPort);
|
|
449
|
-
|
|
450
|
-
const rinfo = { address: udpAddress, port: udpPort };
|
|
451
|
-
if (monitoringHooks.packetCapture) {
|
|
452
|
-
monitoringHooks.packetCapture.capture(packet, "send", rinfo);
|
|
453
|
-
}
|
|
454
|
-
if (monitoringHooks.packetInspector) {
|
|
455
|
-
monitoringHooks.packetInspector.inspect(packet, "send", rinfo);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
metrics.bandwidth.metaBytesOut = (metrics.bandwidth.metaBytesOut || 0) + packet.length;
|
|
459
|
-
metrics.bandwidth.metaPacketsOut = (metrics.bandwidth.metaPacketsOut || 0) + 1;
|
|
460
|
-
metrics.bandwidth.bytesOut += packet.length;
|
|
461
|
-
metrics.bandwidth.packetsOut++;
|
|
572
|
+
recordSentMetadataPacket(packet, udpAddress, udpPort);
|
|
462
573
|
}
|
|
463
574
|
// Count one envelope per call (a multi-chunk envelope is logically one
|
|
464
575
|
// snapshot/diff, even though it shows up in metaPacketsOut as N).
|
|
@@ -481,6 +592,35 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
481
592
|
throw error instanceof Error ? error : new Error(msg);
|
|
482
593
|
}
|
|
483
594
|
}
|
|
595
|
+
async function sendSourceSnapshot(sources, secretKey, udpAddress, udpPort) {
|
|
596
|
+
try {
|
|
597
|
+
if (!state.options) {
|
|
598
|
+
app.debug("sendSourceSnapshot called but plugin is stopped, ignoring");
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (!sources || Object.keys(sources).length === 0) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const useMsgpack = !!state.options.useMsgpack;
|
|
605
|
+
const envelopeSeq = sourceEnvelopeSeq++ >>> 0;
|
|
606
|
+
const chunks = await chunkSourceSnapshot(sources, envelopeSeq, useMsgpack, secretKey);
|
|
607
|
+
for (const { packet } of chunks) {
|
|
608
|
+
if (packet.length > constants_1.MAX_SAFE_UDP_PAYLOAD) {
|
|
609
|
+
app.debug(`Warning: v2 source snapshot packet size ${packet.length} bytes exceeds safe MTU (${constants_1.MAX_SAFE_UDP_PAYLOAD}), may fragment.`);
|
|
610
|
+
metrics.smartBatching.oversizedPackets++;
|
|
611
|
+
}
|
|
612
|
+
await udpSendAsync(packet, udpAddress, udpPort);
|
|
613
|
+
recordSentMetadataPacket(packet, udpAddress, udpPort);
|
|
614
|
+
}
|
|
615
|
+
app.debug(`v2 source snapshot sent: sources=${Object.keys(sources).length}, chunks=${chunks.length}, envSeq=${envelopeSeq}`);
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
619
|
+
app.error(`v2 sendSourceSnapshot error: ${msg}`);
|
|
620
|
+
recordError("general", `v2 sendSourceSnapshot error: ${msg}`);
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
484
624
|
function setMetaRequestHandler(handler) {
|
|
485
625
|
metaRequestHandler = handler;
|
|
486
626
|
}
|
|
@@ -775,7 +915,8 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
775
915
|
});
|
|
776
916
|
}
|
|
777
917
|
}
|
|
778
|
-
//
|
|
918
|
+
// RTT is always published — operators rely on it for link-health visibility
|
|
919
|
+
// even when skipOwnData suppresses the rest of edge-link's own metrics.
|
|
779
920
|
if (!telemetrySendInFlight &&
|
|
780
921
|
state.readyToSend &&
|
|
781
922
|
state.options &&
|
|
@@ -783,6 +924,23 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
783
924
|
state.options.secretKey &&
|
|
784
925
|
state.options.udpAddress &&
|
|
785
926
|
state.options.udpPort) {
|
|
927
|
+
const rttValues = [{ path: "networking.edgeLink.rtt", value: metrics.rtt || 0 }];
|
|
928
|
+
const extraValues = state.options.skipOwnData
|
|
929
|
+
? []
|
|
930
|
+
: [
|
|
931
|
+
{ path: "networking.edgeLink.jitter", value: metrics.jitter || 0 },
|
|
932
|
+
{ path: "networking.edgeLink.packetLoss", value: packetLoss },
|
|
933
|
+
{
|
|
934
|
+
path: "networking.edgeLink.retransmissions",
|
|
935
|
+
value: metrics.retransmissions || 0
|
|
936
|
+
},
|
|
937
|
+
{ path: "networking.edgeLink.queueDepth", value: retransmitQueue.getSize() },
|
|
938
|
+
{ path: "networking.edgeLink.retransmitRate", value: retransmitRate },
|
|
939
|
+
{
|
|
940
|
+
path: "networking.edgeLink.activeLink",
|
|
941
|
+
value: bondingManager ? bondingManager.getActiveLinkName() : "primary"
|
|
942
|
+
}
|
|
943
|
+
];
|
|
786
944
|
const telemetryDelta = {
|
|
787
945
|
context: "vessels.self",
|
|
788
946
|
updates: [
|
|
@@ -792,21 +950,7 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
792
950
|
type: "plugin"
|
|
793
951
|
},
|
|
794
952
|
timestamp: new Date().toISOString(),
|
|
795
|
-
values: [
|
|
796
|
-
{ path: "networking.edgeLink.rtt", value: metrics.rtt || 0 },
|
|
797
|
-
{ path: "networking.edgeLink.jitter", value: metrics.jitter || 0 },
|
|
798
|
-
{ path: "networking.edgeLink.packetLoss", value: packetLoss },
|
|
799
|
-
{
|
|
800
|
-
path: "networking.edgeLink.retransmissions",
|
|
801
|
-
value: metrics.retransmissions || 0
|
|
802
|
-
},
|
|
803
|
-
{ path: "networking.edgeLink.queueDepth", value: retransmitQueue.getSize() },
|
|
804
|
-
{ path: "networking.edgeLink.retransmitRate", value: retransmitRate },
|
|
805
|
-
{
|
|
806
|
-
path: "networking.edgeLink.activeLink",
|
|
807
|
-
value: bondingManager ? bondingManager.getActiveLinkName() : "primary"
|
|
808
|
-
}
|
|
809
|
-
]
|
|
953
|
+
values: [...rttValues, ...extraValues]
|
|
810
954
|
}
|
|
811
955
|
]
|
|
812
956
|
};
|
|
@@ -956,6 +1100,7 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
956
1100
|
return {
|
|
957
1101
|
sendDelta,
|
|
958
1102
|
sendMetadata,
|
|
1103
|
+
sendSourceSnapshot,
|
|
959
1104
|
setMetaRequestHandler,
|
|
960
1105
|
getPacketBuilder,
|
|
961
1106
|
getRetransmitQueue,
|