signalk-edge-link 2.4.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/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;
@@ -378,6 +383,40 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
378
383
  app.debug(`[${instanceId}] META_REQUEST snapshot failed: ${msg}`);
379
384
  });
380
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
+ }
381
420
  /** Thin wrapper around the parser in `metadata.ts` so the instance log
382
421
  * line is tagged with this connection's instanceId. Errors from the
383
422
  * shared parser already have the `[meta-config]` prefix. */
@@ -950,6 +989,10 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
950
989
  state.readyToSend = true;
951
990
  _setStatus("UDP socket recovered", true);
952
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
+ });
953
996
  // A socket-level recovery is the strongest local signal that the
954
997
  // remote receiver may have restarted. Re-prime its meta cache
955
998
  // with a full snapshot so it doesn't have to wait a full
@@ -1027,6 +1070,11 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1027
1070
  state.heartbeatHandle = v2Pipeline.startHeartbeat(options.udpAddress ?? "", options.udpPort, {
1028
1071
  heartbeatInterval: options.heartbeatInterval
1029
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
+ });
1030
1078
  state.socketUdp.on("message", (msg, rinfo) => {
1031
1079
  v2Pipeline.handleControlPacket(msg, rinfo).catch((err) => {
1032
1080
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -1104,6 +1152,8 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1104
1152
  state.helloMessageSender = null;
1105
1153
  clearInterval(state.metaTimer ?? undefined);
1106
1154
  state.metaTimer = null;
1155
+ clearInterval(state.sourceSnapshotTimer ?? undefined);
1156
+ state.sourceSnapshotTimer = null;
1107
1157
  clearTimeout(state.metaDiffFlushTimer ?? undefined);
1108
1158
  state.metaDiffFlushTimer = null;
1109
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
  }
@@ -960,6 +1100,7 @@ function createPipelineV2Client(app, state, metricsApi) {
960
1100
  return {
961
1101
  sendDelta,
962
1102
  sendMetadata,
1103
+ sendSourceSnapshot,
963
1104
  setMetaRequestHandler,
964
1105
  getPacketBuilder,
965
1106
  getRetransmitQueue,
@@ -56,6 +56,8 @@ const msgpack = __importStar(require("@msgpack/msgpack"));
56
56
  const crypto_1 = require("./crypto");
57
57
  const pathDictionary_1 = require("./pathDictionary");
58
58
  const delta_sanitizer_1 = require("./delta-sanitizer");
59
+ const source_dispatch_1 = require("./source-dispatch");
60
+ const source_snapshot_1 = require("./source-snapshot");
59
61
  const packet_1 = require("./packet");
60
62
  const sequence_1 = require("./sequence");
61
63
  const metrics_publisher_1 = require("./metrics-publisher");
@@ -154,6 +156,8 @@ function createPipelineV2Server(app, state, metricsApi) {
154
156
  // packet can still be processed for this request without polluting state.
155
157
  return {
156
158
  key,
159
+ sourceClientInstanceId: null,
160
+ clientId: null,
157
161
  address: rinfo.address,
158
162
  port: rinfo.port,
159
163
  sequenceTracker: new sequence_1.SequenceTracker({
@@ -175,11 +179,15 @@ function createPipelineV2Server(app, state, metricsApi) {
175
179
  rateLimitWindowStart: Date.now(),
176
180
  metaRequested: false,
177
181
  lastMetaEnvSeq: null,
178
- seenMetaChunkIdx: new Set()
182
+ seenMetaChunkIdx: new Set(),
183
+ lastSourceEnvSeq: null,
184
+ seenSourceChunkIdx: new Set()
179
185
  };
180
186
  }
181
187
  const session = {
182
188
  key,
189
+ sourceClientInstanceId: null,
190
+ clientId: null,
183
191
  address: rinfo.address,
184
192
  port: rinfo.port,
185
193
  sequenceTracker: new sequence_1.SequenceTracker({
@@ -206,7 +214,9 @@ function createPipelineV2Server(app, state, metricsApi) {
206
214
  metaRequested: false,
207
215
  // Stale-envelope rejection for METADATA packets
208
216
  lastMetaEnvSeq: null,
209
- seenMetaChunkIdx: new Set()
217
+ seenMetaChunkIdx: new Set(),
218
+ lastSourceEnvSeq: null,
219
+ seenSourceChunkIdx: new Set()
210
220
  };
211
221
  clientSessions.set(key, session);
212
222
  app.debug(`[v2-server] new client session: ${key}`);
@@ -252,6 +262,8 @@ function createPipelineV2Server(app, state, metricsApi) {
252
262
  let lastMetricsTime = Date.now();
253
263
  let lastBytesReceived = 0;
254
264
  let lastPacketsReceived = 0;
265
+ let previousSourceMissingIdentity = 0;
266
+ let previousSourceConflicts = 0;
255
267
  // Rate-limit operator-visible warnings for protocol-version mismatches so a
256
268
  // persistently misconfigured peer is noticeable in logs without flooding them.
257
269
  const PROTOCOL_VERSION_MISMATCH_WARN_INTERVAL_MS = 60000;
@@ -450,6 +462,61 @@ function createPipelineV2Server(app, state, metricsApi) {
450
462
  ackTimer = null;
451
463
  }
452
464
  }
465
+ function shouldDropEnvelopeBySeq(session, env, channel) {
466
+ if (!session || typeof env.seq !== "number" || !Number.isFinite(env.seq)) {
467
+ return false;
468
+ }
469
+ const envSeq = env.seq >>> 0;
470
+ const envIdx = typeof env.idx === "number" && Number.isFinite(env.idx) ? env.idx >>> 0 : 0;
471
+ const isSource = channel === "source snapshot";
472
+ const seenChunkIdx = isSource ? session.seenSourceChunkIdx : session.seenMetaChunkIdx;
473
+ let lastEnvSeq = isSource ? session.lastSourceEnvSeq : session.lastMetaEnvSeq;
474
+ const setLastEnvSeq = (value) => {
475
+ if (isSource) {
476
+ session.lastSourceEnvSeq = value;
477
+ }
478
+ else {
479
+ session.lastMetaEnvSeq = value;
480
+ }
481
+ lastEnvSeq = value;
482
+ };
483
+ // Sender-restart detection: the client's envelope sequence counter is
484
+ // initialised to 0 at process start, so an incoming envSeq of 0 with a
485
+ // sufficiently-advanced previous seq is a strong signal that the peer
486
+ // restarted. The threshold guards against first-packet replays.
487
+ if (lastEnvSeq !== null && envSeq === 0 && lastEnvSeq >= META_RESTART_THRESHOLD) {
488
+ app.debug(`[v2-server] ${channel} sender restart detected for ${session.key} ` +
489
+ `(last seq was ${lastEnvSeq}); resetting ${channel} state`);
490
+ setLastEnvSeq(null);
491
+ seenChunkIdx.clear();
492
+ if (!isSource) {
493
+ session.metaRequested = false;
494
+ }
495
+ }
496
+ if (lastEnvSeq !== null) {
497
+ const distance = (envSeq - lastEnvSeq) >>> 0;
498
+ if (distance !== 0 && distance >= 0x80000000) {
499
+ metrics.duplicatePackets = (metrics.duplicatePackets || 0) + 1;
500
+ app.debug(`[v2-server] stale ${channel} envelope seq=${envSeq} from ${session.key} (last=${lastEnvSeq}), dropping`);
501
+ return true;
502
+ }
503
+ if (distance !== 0) {
504
+ setLastEnvSeq(envSeq);
505
+ seenChunkIdx.clear();
506
+ }
507
+ else if (seenChunkIdx.has(envIdx)) {
508
+ metrics.duplicatePackets = (metrics.duplicatePackets || 0) + 1;
509
+ app.debug(`[v2-server] duplicate ${channel} chunk seq=${envSeq} idx=${envIdx} from ${session.key}, dropping`);
510
+ return true;
511
+ }
512
+ }
513
+ else {
514
+ setLastEnvSeq(envSeq);
515
+ seenChunkIdx.clear();
516
+ }
517
+ seenChunkIdx.add(envIdx);
518
+ return false;
519
+ }
453
520
  /**
454
521
  * Decrypt and dispatch a METADATA (0x06) packet.
455
522
  *
@@ -496,12 +563,20 @@ function createPipelineV2Server(app, state, metricsApi) {
496
563
  return;
497
564
  }
498
565
  const env = content;
499
- if (!Array.isArray(env.entries) || env.entries.length === 0) {
566
+ const hasSourceSnapshot = env.kind === "sources" &&
567
+ env.sources !== null &&
568
+ typeof env.sources === "object" &&
569
+ !Array.isArray(env.sources);
570
+ const entries = Array.isArray(env.entries) ? env.entries : [];
571
+ if (!hasSourceSnapshot && entries.length === 0) {
500
572
  metrics.malformedPackets = (metrics.malformedPackets || 0) + 1;
501
573
  app.debug("v2 META envelope has no entries, dropping");
502
574
  recordError("general", "v2 META envelope has no entries");
503
575
  return;
504
576
  }
577
+ if (hasSourceSnapshot && shouldDropEnvelopeBySeq(session, env, "source snapshot")) {
578
+ return;
579
+ }
505
580
  // Drop stale/duplicate envelopes that UDP reordered or replayed. The
506
581
  // inner envelope `seq` identifies a batch (shared across all chunks of
507
582
  // a multi-chunk snapshot/diff); the inner `idx` identifies a specific
@@ -510,7 +585,10 @@ function createPipelineV2Server(app, state, metricsApi) {
510
585
  // - within the current batch, reject any (seq, idx) pair already
511
586
  // processed ("exact replay"); other idx values for the same seq
512
587
  // remain accepted so multi-chunk batches still apply in full.
513
- if (session && typeof env.seq === "number" && Number.isFinite(env.seq)) {
588
+ if (!hasSourceSnapshot &&
589
+ session &&
590
+ typeof env.seq === "number" &&
591
+ Number.isFinite(env.seq)) {
514
592
  const envSeq = env.seq >>> 0;
515
593
  const envIdx = typeof env.idx === "number" && Number.isFinite(env.idx) ? env.idx >>> 0 : 0;
516
594
  // Sender-restart detection: the client's meta sequence counter is
@@ -558,12 +636,17 @@ function createPipelineV2Server(app, state, metricsApi) {
558
636
  }
559
637
  session.seenMetaChunkIdx.add(envIdx);
560
638
  }
639
+ if (hasSourceSnapshot) {
640
+ const added = (0, source_snapshot_1.mergeSourceSnapshot)(app, env.sources);
641
+ app.debug(`v2 source snapshot received: sources=${Object.keys(env.sources || {}).length}, added=${added}, envSeq=${env.seq ?? "?"}`);
642
+ return;
643
+ }
561
644
  // Group entries by context so the local Signal K server sees one delta
562
645
  // per context rather than one per path. Reduces app.handleMessage
563
646
  // overhead on big snapshots without changing semantics.
564
647
  const nowIso = new Date().toISOString();
565
648
  const byContext = new Map();
566
- for (const rawEntry of env.entries) {
649
+ for (const rawEntry of entries) {
567
650
  if (!rawEntry || typeof rawEntry.meta !== "object" || !rawEntry.meta) {
568
651
  continue;
569
652
  }
@@ -605,7 +688,7 @@ function createPipelineV2Server(app, state, metricsApi) {
605
688
  };
606
689
  app.handleMessage("", deltaMessage);
607
690
  }
608
- app.debug(`v2 meta received: kind=${env.kind ?? "?"}, entries=${env.entries.length}, contexts=${byContext.size}, envSeq=${env.v ?? "?"}`);
691
+ app.debug(`v2 meta received: kind=${env.kind ?? "?"}, entries=${entries.length}, contexts=${byContext.size}, envSeq=${env.seq ?? "?"}`);
609
692
  }
610
693
  catch (err) {
611
694
  const msg = err instanceof Error ? err.message : String(err);
@@ -712,6 +795,16 @@ function createPipelineV2Server(app, state, metricsApi) {
712
795
  try {
713
796
  const info = JSON.parse(parsed.payload.toString());
714
797
  app.debug(`v2 hello from client: ${JSON.stringify(info)}`);
798
+ if (session && info && typeof info === "object") {
799
+ const helloClientId = typeof info.clientId === "string" && info.clientId.trim()
800
+ ? info.clientId.trim()
801
+ : null;
802
+ const helloInstanceId = typeof info.instanceId === "string" && info.instanceId.trim()
803
+ ? info.instanceId.trim()
804
+ : null;
805
+ session.clientId = helloClientId;
806
+ session.sourceClientInstanceId = helloInstanceId || helloClientId;
807
+ }
715
808
  }
716
809
  catch (parseErr) {
717
810
  app.error(`v2 failed to parse HELLO payload: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
@@ -879,12 +972,25 @@ function createPipelineV2Server(app, state, metricsApi) {
879
972
  continue;
880
973
  }
881
974
  deltaMessage = sanitizedDelta;
975
+ deltaMessage = (0, source_dispatch_1.normalizeDeltaSourceRefs)(deltaMessage);
882
976
  _ingestRemoteTelemetry(deltaMessage);
883
977
  if (!Array.isArray(deltaMessage.updates) || deltaMessage.updates.length === 0) {
884
978
  continue;
885
979
  }
980
+ if (state.sourceRegistry && typeof state.sourceRegistry.upsertFromDelta === "function") {
981
+ const deltaRecord = deltaMessage;
982
+ const deltaSourceInstanceId = deltaMessage &&
983
+ typeof deltaMessage === "object" &&
984
+ typeof deltaRecord.sourceClientInstanceId === "string"
985
+ ? deltaRecord.sourceClientInstanceId || null
986
+ : null;
987
+ const stableSourceClientId = (session && (session.sourceClientInstanceId || session.clientId)) ||
988
+ deltaSourceInstanceId ||
989
+ "unknown";
990
+ state.sourceRegistry.upsertFromDelta(deltaMessage, stableSourceClientId);
991
+ }
886
992
  trackPathStats(deltaMessage, decompressed.length / deltas.length);
887
- app.handleMessage("", deltaMessage);
993
+ (0, source_dispatch_1.handleMessageBySource)(app, deltaMessage);
888
994
  metrics.deltasReceived++;
889
995
  }
890
996
  app.debug(`v2 received: seq=${parsed.sequence}, ${deltaCount} deltas, ${packet.length} bytes`);
@@ -943,7 +1049,8 @@ function createPipelineV2Server(app, state, metricsApi) {
943
1049
  sessions,
944
1050
  totalSessions: clientSessions.size,
945
1051
  acksSent: metrics.acksSent,
946
- naksSent: metrics.naksSent
1052
+ naksSent: metrics.naksSent,
1053
+ sourceReplication: state.sourceRegistry ? state.sourceRegistry.getMetrics() : null
947
1054
  };
948
1055
  }
949
1056
  /**
@@ -1027,6 +1134,16 @@ function createPipelineV2Server(app, state, metricsApi) {
1027
1134
  const effectiveQueueDepth = hasRemoteTelemetry ? remote.queueDepth || 0 : 0;
1028
1135
  const effectiveRetransmitRate = hasRemoteTelemetry ? remote.retransmitRate || 0 : 0;
1029
1136
  const effectiveActiveLink = hasRemoteTelemetry ? remote.activeLink || "primary" : "primary";
1137
+ const sourceReplicationMetrics = state.sourceRegistry
1138
+ ? state.sourceRegistry.getMetrics()
1139
+ : { upserts: 0, noops: 0, missingIdentity: 0, conflicts: 0 };
1140
+ const deltaMissing = sourceReplicationMetrics.missingIdentity - previousSourceMissingIdentity;
1141
+ const deltaConflicts = sourceReplicationMetrics.conflicts - previousSourceConflicts;
1142
+ if (deltaMissing > 0 || deltaConflicts > 0) {
1143
+ app.debug(`[source-replication] +missingIdentity=${deltaMissing} +conflicts=${deltaConflicts} totalMissingIdentity=${sourceReplicationMetrics.missingIdentity} totalConflicts=${sourceReplicationMetrics.conflicts} size=${state.sourceRegistry.snapshot().size}`);
1144
+ }
1145
+ previousSourceMissingIdentity = sourceReplicationMetrics.missingIdentity;
1146
+ previousSourceConflicts = sourceReplicationMetrics.conflicts;
1030
1147
  // Publish to Signal K
1031
1148
  metricsPublisher.publish({
1032
1149
  rtt: effectiveRtt,
package/lib/pipeline.js CHANGED
@@ -36,6 +36,7 @@ const msgpack = __importStar(require("@msgpack/msgpack"));
36
36
  const crypto_1 = require("./crypto");
37
37
  const pathDictionary_1 = require("./pathDictionary");
38
38
  const delta_sanitizer_1 = require("./delta-sanitizer");
39
+ const source_dispatch_1 = require("./source-dispatch");
39
40
  const pipeline_utils_1 = require("./pipeline-utils");
40
41
  const constants_1 = require("./constants");
41
42
  const metadata_1 = require("./metadata");
@@ -294,9 +295,10 @@ function createPipeline(app, state, metricsApi) {
294
295
  app.debug(`Skipping delta with no valid Signal K values at index ${i}`);
295
296
  continue;
296
297
  }
298
+ deltaMessage = (0, source_dispatch_1.normalizeDeltaSourceRefs)(deltaMessage);
297
299
  // Track path stats for server-side analytics
298
300
  trackPathStats(deltaMessage, decompressed.length / deltas.length);
299
- app.handleMessage("", deltaMessage);
301
+ (0, source_dispatch_1.handleMessageBySource)(app, deltaMessage);
300
302
  // Log a compact summary only — never log full delta values which may
301
303
  // contain sensitive data (position, fuel, MMSI) in plaintext logs.
302
304
  app.debug(`delta ctx=${deltaMessage.context ?? "?"} updates=${Array.isArray(deltaMessage.updates) ? deltaMessage.updates.length : 0}`);
@@ -9,7 +9,7 @@ const prometheus_1 = require("../prometheus");
9
9
  * @param ctx - Shared route context (helpers, middleware, registry)
10
10
  */
11
11
  function register(router, ctx) {
12
- const { rateLimitMiddleware, instanceRegistry, getFirstBundle, getEffectiveNetworkQuality, getActiveMetricsPublisher, buildFullMetricsResponse } = ctx;
12
+ const { rateLimitMiddleware, instanceRegistry, getFirstBundle, getEffectiveNetworkQuality, getActiveMetricsPublisher, buildFullMetricsResponse, managementAuthMiddleware } = ctx;
13
13
  router.get("/metrics", rateLimitMiddleware, (req, res) => {
14
14
  try {
15
15
  const bundle = getFirstBundle();
@@ -124,4 +124,28 @@ function register(router, ctx) {
124
124
  res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
125
125
  }
126
126
  });
127
+ router.get("/sources", rateLimitMiddleware, managementAuthMiddleware("sources.read"), (req, res) => {
128
+ try {
129
+ const serverBundle = instanceRegistry
130
+ .getAll()
131
+ .find((bundle) => bundle.state && bundle.state.isServerMode && bundle.state.sourceRegistry) || null;
132
+ const bundle = serverBundle || getFirstBundle();
133
+ if (!bundle) {
134
+ return res.status(503).json({ error: "Plugin not started" });
135
+ }
136
+ const { state } = bundle;
137
+ if (!state.sourceRegistry) {
138
+ return res.json({
139
+ schemaVersion: 1,
140
+ size: 0,
141
+ sources: [],
142
+ legacy: { byLabel: {}, bySourceRef: {} }
143
+ });
144
+ }
145
+ return res.json(state.sourceRegistry.snapshot());
146
+ }
147
+ catch (err) {
148
+ return res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
149
+ }
150
+ });
127
151
  }
package/lib/routes.js CHANGED
@@ -442,6 +442,12 @@ function createRoutes(app, instanceRegistry, pluginRef) {
442
442
  timestamp: metrics.lastErrorTime,
443
443
  timeAgo: metrics.lastErrorTime ? Date.now() - metrics.lastErrorTime : null
444
444
  }
445
+ : null,
446
+ sourceReplication: state.sourceRegistry
447
+ ? {
448
+ metrics: state.sourceRegistry.getMetrics(),
449
+ registry: null
450
+ }
445
451
  : null
446
452
  };
447
453
  return metricsData;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeDeltaSourceRefs = normalizeDeltaSourceRefs;
4
+ exports.handleMessageBySource = handleMessageBySource;
5
+ function isRecord(value) {
6
+ return value !== null && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+ function getSourceLabel(update) {
9
+ const source = isRecord(update.source) ? update.source : null;
10
+ const label = source && typeof source.label === "string" ? source.label.trim() : "";
11
+ return label.length > 0 ? label : "";
12
+ }
13
+ function hasStaleEdgeLinkSourceRef(update) {
14
+ const sourceLabel = getSourceLabel(update);
15
+ const sourceRef = typeof update.$source === "string" ? update.$source.trim() : "";
16
+ if (!sourceLabel || !sourceRef || sourceLabel === "signalk-edge-link") {
17
+ return false;
18
+ }
19
+ return (sourceRef === "signalk-edge-link" ||
20
+ sourceRef.startsWith("signalk-edge-link.") ||
21
+ sourceRef.startsWith("signalk-edge-link:"));
22
+ }
23
+ function normalizeUpdateSourceRef(update) {
24
+ if (!hasStaleEdgeLinkSourceRef(update)) {
25
+ return update;
26
+ }
27
+ const cloned = { ...update };
28
+ delete cloned.$source;
29
+ return cloned;
30
+ }
31
+ function cloneUpdate(update) {
32
+ const normalized = normalizeUpdateSourceRef(update);
33
+ const cloned = {
34
+ ...normalized,
35
+ source: isRecord(normalized.source)
36
+ ? { ...normalized.source }
37
+ : normalized.source,
38
+ values: Array.isArray(normalized.values)
39
+ ? normalized.values.map((value) => ({ ...value }))
40
+ : normalized.values,
41
+ meta: Array.isArray(normalized.meta)
42
+ ? normalized.meta.map((entry) => ({ ...entry }))
43
+ : normalized.meta
44
+ };
45
+ return cloned;
46
+ }
47
+ function normalizeDeltaSourceRefs(delta) {
48
+ if (!delta || !Array.isArray(delta.updates)) {
49
+ return delta;
50
+ }
51
+ let changed = false;
52
+ const updates = delta.updates.map((update) => {
53
+ const normalized = normalizeUpdateSourceRef(update);
54
+ if (normalized !== update) {
55
+ changed = true;
56
+ }
57
+ return normalized;
58
+ });
59
+ return changed ? { ...delta, updates } : delta;
60
+ }
61
+ /**
62
+ * Signal K's app.handleMessage(providerId, delta) rewrites update.source.label
63
+ * to providerId before applying the delta. Remote updates can contain several
64
+ * original source labels, so dispatch them under their original label. Stale
65
+ * edge-link `$source` values are removed separately before dispatch so Signal K
66
+ * can recompute them from the structured source object.
67
+ */
68
+ function handleMessageBySource(app, delta) {
69
+ if (!delta || !Array.isArray(delta.updates) || delta.updates.length === 0) {
70
+ return;
71
+ }
72
+ const grouped = new Map();
73
+ let hasOriginalSourceLabel = false;
74
+ for (const update of delta.updates) {
75
+ const sourceLabel = getSourceLabel(update);
76
+ if (sourceLabel) {
77
+ hasOriginalSourceLabel = true;
78
+ }
79
+ const providerId = sourceLabel || "";
80
+ const updates = grouped.get(providerId);
81
+ if (updates) {
82
+ updates.push(update);
83
+ }
84
+ else {
85
+ grouped.set(providerId, [update]);
86
+ }
87
+ }
88
+ if (!hasOriginalSourceLabel) {
89
+ app.handleMessage("", delta);
90
+ return;
91
+ }
92
+ for (const [providerId, updates] of grouped) {
93
+ app.handleMessage(providerId, {
94
+ ...delta,
95
+ updates: updates.map(cloneUpdate)
96
+ });
97
+ }
98
+ }
@@ -0,0 +1,241 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SOURCE_REPLICATION_SCHEMA_VERSION = void 0;
7
+ exports.createSourceRegistry = createSourceRegistry;
8
+ const node_crypto_1 = __importDefault(require("node:crypto"));
9
+ exports.SOURCE_REPLICATION_SCHEMA_VERSION = 1;
10
+ function normalizeText(value) {
11
+ if (typeof value !== "string") {
12
+ return undefined;
13
+ }
14
+ const trimmed = value.trim();
15
+ return trimmed.length > 0 ? trimmed : undefined;
16
+ }
17
+ function sanitizeKeyPart(value) {
18
+ return value
19
+ .toLowerCase()
20
+ .replace(/[^a-z0-9._:-]+/g, "-")
21
+ .replace(/^-+|-+$/g, "")
22
+ .slice(0, 128);
23
+ }
24
+ function toCanonicalIdentity(update, sourceClientInstanceId) {
25
+ const source = update.source && typeof update.source === "object"
26
+ ? update.source
27
+ : undefined;
28
+ const sourceRef = normalizeText(update.$source);
29
+ const label = normalizeText(source?.label) || (sourceRef ? `legacy:${sourceRef}` : undefined);
30
+ const type = normalizeText(source?.type) || (sourceRef ? "legacy" : "unknown");
31
+ const src = normalizeText(source?.src);
32
+ const instance = normalizeText(source?.instance);
33
+ const pgn = Number.isFinite(Number(source?.pgn)) ? Number(source?.pgn) : undefined;
34
+ const parsedDeviceId = normalizeText(source?.deviceId);
35
+ const hasMetadata = !!label ||
36
+ !!sourceRef ||
37
+ src !== undefined ||
38
+ instance !== undefined ||
39
+ pgn !== undefined ||
40
+ parsedDeviceId !== undefined;
41
+ if (!label &&
42
+ !sourceRef &&
43
+ src === undefined &&
44
+ instance === undefined &&
45
+ pgn === undefined &&
46
+ parsedDeviceId === undefined) {
47
+ return null;
48
+ }
49
+ return {
50
+ label: label || "unknown-source",
51
+ type: normalizeText(type) || "unknown",
52
+ src,
53
+ instance,
54
+ pgn,
55
+ deviceId: parsedDeviceId ||
56
+ (hasMetadata && sourceClientInstanceId ? sanitizeKeyPart(sourceClientInstanceId) : undefined)
57
+ };
58
+ }
59
+ function createSourceKey(update, identity) {
60
+ const sourceRef = normalizeText(update.$source);
61
+ if (sourceRef) {
62
+ return `source-ref:${sanitizeKeyPart(sourceRef)}`;
63
+ }
64
+ if (identity) {
65
+ const canonicalIdentity = JSON.stringify({
66
+ type: identity.type || "",
67
+ label: identity.label || "",
68
+ src: identity.src || "",
69
+ instance: identity.instance || "",
70
+ pgn: identity.pgn ?? "",
71
+ deviceId: identity.deviceId || ""
72
+ });
73
+ const identityHash = node_crypto_1.default.createHash("sha256").update(canonicalIdentity).digest("hex");
74
+ return `source-identity:${identityHash}`;
75
+ }
76
+ return "source-identity:unknown";
77
+ }
78
+ function canonicalizeForHash(value) {
79
+ if (Array.isArray(value)) {
80
+ return value.map((entry) => canonicalizeForHash(entry));
81
+ }
82
+ if (value && typeof value === "object") {
83
+ const input = value;
84
+ const out = {};
85
+ for (const key of Object.keys(input).sort()) {
86
+ out[key] = canonicalizeForHash(input[key]);
87
+ }
88
+ return out;
89
+ }
90
+ return value;
91
+ }
92
+ function toMergeHash(record) {
93
+ const stablePayload = {
94
+ schemaVersion: record.schemaVersion,
95
+ key: record.key,
96
+ identity: record.identity,
97
+ metadata: record.metadata,
98
+ provenance: record.provenance,
99
+ raw: record.raw
100
+ };
101
+ const canonical = canonicalizeForHash(stablePayload);
102
+ return node_crypto_1.default.createHash("sha1").update(JSON.stringify(canonical)).digest("hex");
103
+ }
104
+ function chooseValue(current, incoming, currentTs, incomingTs, conflicts) {
105
+ if (incoming === undefined || incoming === null || incoming === "") {
106
+ return current;
107
+ }
108
+ if (current === undefined || current === null || current === "") {
109
+ return incoming;
110
+ }
111
+ if (JSON.stringify(current) === JSON.stringify(incoming)) {
112
+ return current;
113
+ }
114
+ conflicts.count++;
115
+ return incomingTs >= currentTs ? incoming : current;
116
+ }
117
+ function createSourceRegistry(app) {
118
+ const records = new Map();
119
+ let lastLoggedRegistrySize = 0;
120
+ const metrics = {
121
+ upserts: 0,
122
+ noops: 0,
123
+ missingIdentity: 0,
124
+ conflicts: 0
125
+ };
126
+ function upsertFromDelta(delta, sourceClientInstanceId) {
127
+ if (!delta || !Array.isArray(delta.updates)) {
128
+ return;
129
+ }
130
+ for (const update of delta.updates) {
131
+ if (!update || typeof update !== "object") {
132
+ continue;
133
+ }
134
+ const identity = toCanonicalIdentity(update, sourceClientInstanceId);
135
+ if (!identity) {
136
+ metrics.missingIdentity++;
137
+ continue;
138
+ }
139
+ const sourceRef = normalizeText(update.$source);
140
+ const key = createSourceKey(update, identity);
141
+ const nowIso = new Date().toISOString();
142
+ const updateTs = normalizeText(update.timestamp);
143
+ const parsedIncomingTs = updateTs ? Date.parse(updateTs) : NaN;
144
+ const updateTsMs = Number.isFinite(parsedIncomingTs) ? parsedIncomingTs : Date.now();
145
+ const sourceObj = update.source && typeof update.source === "object"
146
+ ? { ...update.source }
147
+ : undefined;
148
+ const existing = records.get(key);
149
+ const mergedBase = {
150
+ schemaVersion: exports.SOURCE_REPLICATION_SCHEMA_VERSION,
151
+ key,
152
+ identity: {
153
+ label: identity.label,
154
+ type: identity.type,
155
+ src: identity.src,
156
+ instance: identity.instance,
157
+ pgn: identity.pgn,
158
+ deviceId: identity.deviceId
159
+ },
160
+ metadata: {},
161
+ firstSeenAt: existing ? existing.firstSeenAt : nowIso,
162
+ lastSeenAt: existing ? existing.lastSeenAt : nowIso,
163
+ lastUpdatedAt: existing ? existing.lastUpdatedAt : nowIso,
164
+ provenance: {
165
+ lastUpdatedBy: sourceObj ? "source" : update.$source ? "$source" : "merge",
166
+ sourceClientInstanceId,
167
+ updateTimestamp: updateTs
168
+ },
169
+ raw: {
170
+ source: sourceObj,
171
+ $source: sourceRef
172
+ }
173
+ };
174
+ if (existing) {
175
+ const conflictCounter = { count: 0 };
176
+ const existingUpdateTs = normalizeText(existing.provenance?.updateTimestamp);
177
+ const parsedExistingTs = existingUpdateTs ? Date.parse(existingUpdateTs) : NaN;
178
+ const parsedExistingUpdatedAt = Date.parse(existing.lastUpdatedAt);
179
+ const currentTs = Number.isFinite(parsedExistingTs)
180
+ ? parsedExistingTs
181
+ : Number.isFinite(parsedExistingUpdatedAt)
182
+ ? parsedExistingUpdatedAt
183
+ : Date.now();
184
+ mergedBase.identity.label = chooseValue(existing.identity.label, mergedBase.identity.label, currentTs, updateTsMs, conflictCounter);
185
+ mergedBase.identity.type = chooseValue(existing.identity.type, mergedBase.identity.type, currentTs, updateTsMs, conflictCounter);
186
+ mergedBase.identity.src = chooseValue(existing.identity.src, mergedBase.identity.src, currentTs, updateTsMs, conflictCounter);
187
+ mergedBase.identity.instance = chooseValue(existing.identity.instance, mergedBase.identity.instance, currentTs, updateTsMs, conflictCounter);
188
+ mergedBase.identity.pgn = chooseValue(existing.identity.pgn, mergedBase.identity.pgn, currentTs, updateTsMs, conflictCounter);
189
+ mergedBase.identity.deviceId = chooseValue(existing.identity.deviceId, mergedBase.identity.deviceId, currentTs, updateTsMs, conflictCounter);
190
+ const incomingMeta = mergedBase.metadata;
191
+ const allKeys = new Set([...Object.keys(existing.metadata), ...Object.keys(incomingMeta)]);
192
+ for (const metaKey of allKeys) {
193
+ mergedBase.metadata[metaKey] = chooseValue(existing.metadata[metaKey], incomingMeta[metaKey], currentTs, updateTsMs, conflictCounter);
194
+ }
195
+ metrics.conflicts += conflictCounter.count;
196
+ }
197
+ const mergeHash = toMergeHash(mergedBase);
198
+ if (existing && existing.mergeHash === mergeHash) {
199
+ existing.lastSeenAt = nowIso;
200
+ metrics.noops++;
201
+ continue;
202
+ }
203
+ mergedBase.lastSeenAt = nowIso;
204
+ mergedBase.lastUpdatedAt = nowIso;
205
+ records.set(key, { ...mergedBase, mergeHash });
206
+ metrics.upserts++;
207
+ }
208
+ if (records.size % 50 === 0 && records.size > 0 && records.size !== lastLoggedRegistrySize) {
209
+ app.debug(`[source-replication] registry-size=${records.size}`);
210
+ lastLoggedRegistrySize = records.size;
211
+ }
212
+ }
213
+ function snapshot() {
214
+ const sources = [...records.values()].sort((a, b) => a.key.localeCompare(b.key));
215
+ const legacyByLabel = {};
216
+ const legacyBySourceRef = {};
217
+ for (const source of sources) {
218
+ legacyByLabel[source.identity.label] = source.key;
219
+ if (source.raw.$source) {
220
+ legacyBySourceRef[source.raw.$source] = source.key;
221
+ }
222
+ }
223
+ return {
224
+ schemaVersion: exports.SOURCE_REPLICATION_SCHEMA_VERSION,
225
+ size: sources.length,
226
+ sources,
227
+ legacy: {
228
+ byLabel: legacyByLabel,
229
+ bySourceRef: legacyBySourceRef
230
+ }
231
+ };
232
+ }
233
+ function getMetrics() {
234
+ return { ...metrics };
235
+ }
236
+ return {
237
+ upsertFromDelta,
238
+ snapshot,
239
+ getMetrics
240
+ };
241
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectSourceSnapshot = collectSourceSnapshot;
4
+ exports.mergeSourceSnapshot = mergeSourceSnapshot;
5
+ const BLOCKED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
6
+ function isRecord(value) {
7
+ return value !== null && typeof value === "object" && !Array.isArray(value);
8
+ }
9
+ function clonePlain(value) {
10
+ if (Array.isArray(value)) {
11
+ return value.map((entry) => clonePlain(entry));
12
+ }
13
+ if (!isRecord(value)) {
14
+ return value;
15
+ }
16
+ const out = {};
17
+ for (const [key, entry] of Object.entries(value)) {
18
+ if (!BLOCKED_KEYS.has(key)) {
19
+ out[key] = clonePlain(entry);
20
+ }
21
+ }
22
+ return out;
23
+ }
24
+ function mergePlain(target, incoming) {
25
+ for (const [key, incomingValue] of Object.entries(incoming)) {
26
+ if (BLOCKED_KEYS.has(key)) {
27
+ continue;
28
+ }
29
+ const currentValue = target[key];
30
+ if (isRecord(currentValue) && isRecord(incomingValue)) {
31
+ mergePlain(currentValue, incomingValue);
32
+ }
33
+ else {
34
+ target[key] = clonePlain(incomingValue);
35
+ }
36
+ }
37
+ }
38
+ function getSignalKRoot(app) {
39
+ const signalk = app.signalk;
40
+ if (!signalk || typeof signalk.retrieve !== "function") {
41
+ return null;
42
+ }
43
+ const root = signalk.retrieve();
44
+ return isRecord(root) ? root : null;
45
+ }
46
+ function collectSourceSnapshot(app) {
47
+ const root = getSignalKRoot(app);
48
+ if (!root || !isRecord(root.sources)) {
49
+ return null;
50
+ }
51
+ return clonePlain(root.sources);
52
+ }
53
+ function mergeSourceSnapshot(app, sources) {
54
+ if (!isRecord(sources)) {
55
+ return 0;
56
+ }
57
+ const root = getSignalKRoot(app);
58
+ if (!root) {
59
+ return 0;
60
+ }
61
+ if (!isRecord(root.sources)) {
62
+ root.sources = {};
63
+ }
64
+ const target = root.sources;
65
+ const before = Object.keys(target).length;
66
+ mergePlain(target, sources);
67
+ return Object.keys(target).length - before;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-edge-link",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "SignalK Edge Link. Secure UDP link for data exchange.",
5
5
  "main": "lib/index.js",
6
6
  "files": [