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.
@@ -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
  }
@@ -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
- return (0, delta_sanitizer_1.sanitizeDeltaForSignalK)(delta);
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
- onMeta(pathParts.join("."), obj.meta);
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: m.value
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
- if (monitoringHooks) {
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
- // Send client-side telemetry to the server
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,