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 +51 -1
- package/lib/metadata.js +46 -2
- package/lib/pipeline-v2-client.js +154 -13
- 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/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/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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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=${
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
package/lib/routes/metrics.js
CHANGED
|
@@ -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
|
+
}
|