signalk-edge-link 2.6.3 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/config-watcher.js +42 -4
- package/lib/instance.js +224 -38
- package/lib/metrics.js +5 -1
- package/lib/prometheus.js +13 -0
- package/lib/routes.js +1 -0
- package/lib/source-dispatch.js +130 -65
- package/lib/values-snapshot.js +22 -16
- package/package.json +165 -165
package/lib/config-watcher.js
CHANGED
|
@@ -72,10 +72,48 @@ function createDebouncedConfigHandler(opts) {
|
|
|
72
72
|
app.debug(`[${instanceId}] ${name} file unchanged, skipping`);
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
// Claim the hash BEFORE awaiting processConfig so a concurrent runLoad
|
|
76
|
+
// (e.g. the initial flush() racing an fs.watch event triggered during
|
|
77
|
+
// startup) sees the hash and skips instead of running processConfig
|
|
78
|
+
// twice. Without this, two processConfigs can both observe the same
|
|
79
|
+
// pre-claim hash and both call subscribe(), leaving leaked listeners
|
|
80
|
+
// for any path whose bus is created between the new subscribe() and
|
|
81
|
+
// the old previousUnsubscribes.forEach() in the second call.
|
|
82
|
+
const previousHash = state.configContentHashes[name];
|
|
83
|
+
function revertHashClaim() {
|
|
84
|
+
if (state.configContentHashes[name] !== contentHash)
|
|
85
|
+
return;
|
|
86
|
+
if (previousHash === undefined) {
|
|
87
|
+
delete state.configContentHashes[name];
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
state.configContentHashes[name] = previousHash;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
state.configContentHashes[name] = contentHash;
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = content ? JSON.parse(content) : readFallback;
|
|
97
|
+
}
|
|
98
|
+
catch (parseErr) {
|
|
99
|
+
// Parse failure leaves nothing valid to process. Revert the claim so
|
|
100
|
+
// a subsequent file event (presumably with corrected content) is not
|
|
101
|
+
// silently skipped on the unchanged-hash check.
|
|
102
|
+
revertHashClaim();
|
|
103
|
+
throw parseErr;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
await processConfig(parsed);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
revertHashClaim();
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
// If stop() ran between claim and the processConfig microtask, treat
|
|
113
|
+
// the hash as unclaimed so a fresh start() will reload from disk
|
|
114
|
+
// instead of inheriting our claim (the in-memory state is gone).
|
|
115
|
+
if (state.stopped) {
|
|
116
|
+
revertHashClaim();
|
|
79
117
|
}
|
|
80
118
|
}
|
|
81
119
|
const handleChange = function () {
|
package/lib/instance.js
CHANGED
|
@@ -38,6 +38,11 @@ const values_snapshot_1 = require("./values-snapshot");
|
|
|
38
38
|
const DELTA_SEND_MAX_RETRIES = 1;
|
|
39
39
|
const DELTA_SEND_RETRY_BACKOFF_MS = 100;
|
|
40
40
|
const SOURCE_SNAPSHOT_INTERVAL_MS = 60000;
|
|
41
|
+
// Signal K's subscription manager can deliver the same cached/live delta pair
|
|
42
|
+
// about one fixed-policy window apart. Exact JSON equality keeps this narrow:
|
|
43
|
+
// a fresh timestamp or changed value still forwards normally.
|
|
44
|
+
const OUTBOUND_DUPLICATE_SUPPRESS_MS = 1500;
|
|
45
|
+
const SUPPRESSED_DUPLICATE_STATS_MAX_SIZE = 50;
|
|
41
46
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
42
47
|
/**
|
|
43
48
|
* Derive a URL-safe identifier from a human-readable name.
|
|
@@ -71,7 +76,13 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
71
76
|
socketUdp: null,
|
|
72
77
|
readyToSend: false,
|
|
73
78
|
stopped: false,
|
|
74
|
-
isServerMode
|
|
79
|
+
// Initialise from options so isServerMode() returns the correct value
|
|
80
|
+
// BEFORE start() runs. index.ts filters instances into server and client
|
|
81
|
+
// groups (servers start first) using inst.isServerMode(); if that read
|
|
82
|
+
// happens before start() has had a chance to set state.isServerMode, the
|
|
83
|
+
// server group ends up empty and every instance starts concurrently in
|
|
84
|
+
// the client group, defeating the intended sequencing.
|
|
85
|
+
isServerMode: options.serverType === true || options.serverType === "server",
|
|
75
86
|
deltas: [],
|
|
76
87
|
timer: false,
|
|
77
88
|
batchSendInFlight: false,
|
|
@@ -95,6 +106,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
95
106
|
pingMonitor: null,
|
|
96
107
|
deltaTimer: null,
|
|
97
108
|
subscriptionRetryTimer: null,
|
|
109
|
+
subscribing: false,
|
|
98
110
|
pipeline: null,
|
|
99
111
|
pipelineServer: null,
|
|
100
112
|
heartbeatHandle: null,
|
|
@@ -115,6 +127,9 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
115
127
|
};
|
|
116
128
|
const metricsApi = (0, metrics_1.default)();
|
|
117
129
|
const { metrics, recordError, resetMetrics } = metricsApi;
|
|
130
|
+
const recentOutboundDeltas = new Map();
|
|
131
|
+
let lastOutboundDuplicateLogAt = 0;
|
|
132
|
+
let activeSubscriptionGeneration = 0;
|
|
118
133
|
let v1Pipeline = null;
|
|
119
134
|
function getV1Pipeline() {
|
|
120
135
|
if (!v1Pipeline) {
|
|
@@ -484,6 +499,51 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
484
499
|
function parseMetaConfig(raw) {
|
|
485
500
|
return (0, metadata_1.parseMetaConfig)(raw, (msg) => app.error(msg), instanceId);
|
|
486
501
|
}
|
|
502
|
+
function normalizeSubscriptionConfig(config) {
|
|
503
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
504
|
+
return config;
|
|
505
|
+
}
|
|
506
|
+
const record = config;
|
|
507
|
+
if (!Array.isArray(record.subscribe)) {
|
|
508
|
+
return config;
|
|
509
|
+
}
|
|
510
|
+
const rows = record.subscribe;
|
|
511
|
+
const wildcardRow = rows.find((row) => row &&
|
|
512
|
+
typeof row === "object" &&
|
|
513
|
+
!Array.isArray(row) &&
|
|
514
|
+
row.path === "*");
|
|
515
|
+
if (wildcardRow) {
|
|
516
|
+
if (rows.length > 1) {
|
|
517
|
+
app.debug(`[${instanceId}] Subscription contains path="*"; ignoring ${rows.length - 1} overlapping row(s)`);
|
|
518
|
+
}
|
|
519
|
+
return { ...record, subscribe: [wildcardRow] };
|
|
520
|
+
}
|
|
521
|
+
const seenPaths = new Set();
|
|
522
|
+
const deduped = [];
|
|
523
|
+
let dropped = 0;
|
|
524
|
+
for (const row of rows) {
|
|
525
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
526
|
+
deduped.push(row);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
const path = row.path;
|
|
530
|
+
if (typeof path !== "string") {
|
|
531
|
+
deduped.push(row);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (seenPaths.has(path)) {
|
|
535
|
+
dropped++;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
seenPaths.add(path);
|
|
539
|
+
deduped.push(row);
|
|
540
|
+
}
|
|
541
|
+
if (dropped > 0) {
|
|
542
|
+
app.debug(`[${instanceId}] Removed ${dropped} duplicate subscription row(s)`);
|
|
543
|
+
return { ...record, subscribe: deduped };
|
|
544
|
+
}
|
|
545
|
+
return config;
|
|
546
|
+
}
|
|
487
547
|
/**
|
|
488
548
|
* Processes an incoming delta from the subscription manager.
|
|
489
549
|
* Buffers and dispatches deltas to the send pipeline.
|
|
@@ -559,6 +619,17 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
559
619
|
if (!state.readyToSend) {
|
|
560
620
|
return;
|
|
561
621
|
}
|
|
622
|
+
// Drop signalk-server's synchronous cache replay. handleSubscribeRow
|
|
623
|
+
// calls `latest.forEach(callback)` after registering the live listener,
|
|
624
|
+
// bypassing the bufferWithTime+uniqBy dedupe — so each path with a
|
|
625
|
+
// cached value at subscribe time is delivered to us twice (once direct,
|
|
626
|
+
// once through the live pipeline). `replayValuesSnapshot("initial
|
|
627
|
+
// subscribe")` runs immediately after subscribe() returns and walks
|
|
628
|
+
// the SK tree explicitly to ship initial state downstream, so dropping
|
|
629
|
+
// the direct replay does not cost us any data.
|
|
630
|
+
if (state.subscribing) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
562
633
|
// Capture live meta BEFORE the delta flows into the pipeline encoder,
|
|
563
634
|
// because pathDictionary.transformDelta will strip `updates[].meta[]` when
|
|
564
635
|
// rebuilding the update objects. `extractLiveMeta` returns [] when meta
|
|
@@ -573,6 +644,24 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
573
644
|
if (!outboundDelta) {
|
|
574
645
|
return;
|
|
575
646
|
}
|
|
647
|
+
const now = Date.now();
|
|
648
|
+
const dedupeKey = JSON.stringify(outboundDelta);
|
|
649
|
+
const lastSeenAt = recentOutboundDeltas.get(dedupeKey);
|
|
650
|
+
if (lastSeenAt !== undefined && now - lastSeenAt <= OUTBOUND_DUPLICATE_SUPPRESS_MS) {
|
|
651
|
+
metrics.suppressedOutboundDuplicates = (metrics.suppressedOutboundDuplicates || 0) + 1;
|
|
652
|
+
recordSuppressedDuplicateStats(outboundDelta, now);
|
|
653
|
+
if (now - lastOutboundDuplicateLogAt >= 1000) {
|
|
654
|
+
lastOutboundDuplicateLogAt = now;
|
|
655
|
+
app.debug(`[${instanceId}] Suppressed duplicate outbound delta (${summarizeDeltaForLog(outboundDelta)})`);
|
|
656
|
+
}
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
recentOutboundDeltas.set(dedupeKey, now);
|
|
660
|
+
for (const [key, seenAt] of recentOutboundDeltas) {
|
|
661
|
+
if (now - seenAt > OUTBOUND_DUPLICATE_SUPPRESS_MS) {
|
|
662
|
+
recentOutboundDeltas.delete(key);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
576
665
|
if (state.deltas.length >= constants_1.MAX_DELTAS_BUFFER_SIZE) {
|
|
577
666
|
const dropCount = Math.floor(constants_1.MAX_DELTAS_BUFFER_SIZE * constants_1.DELTA_BUFFER_DROP_RATIO);
|
|
578
667
|
state.deltas.splice(0, dropCount);
|
|
@@ -602,6 +691,14 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
602
691
|
}
|
|
603
692
|
}
|
|
604
693
|
state.processDelta = processDelta;
|
|
694
|
+
function createSubscriptionDeltaHandler(subscriptionGeneration) {
|
|
695
|
+
return (delta) => {
|
|
696
|
+
if (subscriptionGeneration !== activeSubscriptionGeneration) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
processDelta(delta);
|
|
700
|
+
};
|
|
701
|
+
}
|
|
605
702
|
const SUBSCRIPTION_RETRY_BASE_DELAY = 5000;
|
|
606
703
|
const SUBSCRIPTION_RETRY_MAX_DELAY = 300000;
|
|
607
704
|
const SUBSCRIPTION_RETRY_MAX_ATTEMPTS = 10;
|
|
@@ -637,13 +734,27 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
637
734
|
return;
|
|
638
735
|
}
|
|
639
736
|
app.debug(`[${instanceId}] Retrying subscription (attempt ${attempt})...`);
|
|
737
|
+
// Tear down any partial listeners left behind by a previous failed
|
|
738
|
+
// subscribe attempt before adding new ones — same reason as the main
|
|
739
|
+
// handleSubscriptionChange path: keeping stale partial listeners in
|
|
740
|
+
// state.unsubscribes alongside a fresh subscribe() causes them to fire
|
|
741
|
+
// for every push, doubling processDelta delivery for affected paths.
|
|
742
|
+
const partialUnsubscribes = state.unsubscribes.splice(0);
|
|
743
|
+
partialUnsubscribes.forEach((f) => f());
|
|
640
744
|
try {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
745
|
+
const subscriptionGeneration = ++activeSubscriptionGeneration;
|
|
746
|
+
state.subscribing = true;
|
|
747
|
+
try {
|
|
748
|
+
app.subscriptionmanager.subscribe(state.localSubscription, state.unsubscribes, (retrySubError) => {
|
|
749
|
+
app.error(`[${instanceId}] Subscription error (attempt ${attempt}): ${retrySubError}`);
|
|
750
|
+
state.readyToSend = false;
|
|
751
|
+
_setStatus("Subscription error - data transmission paused", false);
|
|
752
|
+
recordError("subscription", `Subscription error: ${retrySubError}`);
|
|
753
|
+
}, createSubscriptionDeltaHandler(subscriptionGeneration));
|
|
754
|
+
}
|
|
755
|
+
finally {
|
|
756
|
+
state.subscribing = false;
|
|
757
|
+
}
|
|
647
758
|
// Retry succeeded — perform the staged commit that the original
|
|
648
759
|
// processConfig catch block skipped. Without this, the operator's
|
|
649
760
|
// new meta block (stashed on state.pendingMetaConfig) would remain
|
|
@@ -676,30 +787,46 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
676
787
|
name: "Subscription",
|
|
677
788
|
getFilePath: () => state.subscriptionFile,
|
|
678
789
|
processConfig: (config) => {
|
|
679
|
-
state.localSubscription = config;
|
|
790
|
+
state.localSubscription = normalizeSubscriptionConfig(config);
|
|
680
791
|
app.debug(`[${instanceId}] Subscription configuration updated`);
|
|
681
792
|
// Stage the new metadata config — do NOT yet touch state.metaConfig,
|
|
682
793
|
// the periodic timer, or metaCache. If subscribe() throws, the old
|
|
683
794
|
// subscription remains active until the retry succeeds, so its
|
|
684
795
|
// previous metadata behaviour must remain intact.
|
|
685
796
|
const previousMetaConfig = state.metaConfig;
|
|
686
|
-
const pendingMetaConfig = parseMetaConfig(
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
797
|
+
const pendingMetaConfig = parseMetaConfig(state.localSubscription);
|
|
798
|
+
// Tear down the old subscription FIRST, then establish the new one.
|
|
799
|
+
// The previous "subscribe-then-unsubscribe" ordering tried to avoid
|
|
800
|
+
// dropping any delta during the handover, but it leaves a window
|
|
801
|
+
// where BOTH the old and new subscriptions are simultaneously
|
|
802
|
+
// attached to every per-path bus in signalk-server's
|
|
803
|
+
// `streambundle.buses`. Any push that lands in that window — or any
|
|
804
|
+
// listener that the new subscribe() registers asynchronously via
|
|
805
|
+
// streambundle.keys.onValue for a path whose bus is created during
|
|
806
|
+
// the window — fires both callbacks, doubling processDelta delivery
|
|
807
|
+
// for the rest of the process lifetime.
|
|
808
|
+
//
|
|
809
|
+
// Replaying via `replayValuesSnapshot("initial subscribe")` below
|
|
810
|
+
// recovers any value that was already in the SK tree, and any
|
|
811
|
+
// genuinely live delta that lands in the brief teardown→subscribe
|
|
812
|
+
// gap will be re-emitted by its publisher within the subscription's
|
|
813
|
+
// throttle period.
|
|
693
814
|
const previousUnsubscribes = state.unsubscribes.splice(0);
|
|
815
|
+
previousUnsubscribes.forEach((f) => f());
|
|
694
816
|
try {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
817
|
+
const subscriptionGeneration = ++activeSubscriptionGeneration;
|
|
818
|
+
state.subscribing = true;
|
|
819
|
+
try {
|
|
820
|
+
app.subscriptionmanager.subscribe(state.localSubscription, state.unsubscribes, (subscriptionError) => {
|
|
821
|
+
app.error(`[${instanceId}] Subscription error: ${subscriptionError}`);
|
|
822
|
+
state.readyToSend = false;
|
|
823
|
+
_setStatus("Subscription error - data transmission paused", false);
|
|
824
|
+
recordError("subscription", `Subscription error: ${subscriptionError}`);
|
|
825
|
+
}, createSubscriptionDeltaHandler(subscriptionGeneration));
|
|
826
|
+
}
|
|
827
|
+
finally {
|
|
828
|
+
state.subscribing = false;
|
|
829
|
+
}
|
|
703
830
|
// Commit the new metadata config AFTER a successful subscribe: swap
|
|
704
831
|
// state.metaConfig, (re)start the periodic timer, and reset the diff
|
|
705
832
|
// cache so the next snapshot represents the live state in full. We
|
|
@@ -721,12 +848,19 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
721
848
|
replayValuesSnapshot("initial subscribe");
|
|
722
849
|
}
|
|
723
850
|
catch (subscribeError) {
|
|
724
|
-
// Re-subscribe failed
|
|
725
|
-
//
|
|
851
|
+
// Re-subscribe failed. The old subscription was already torn down
|
|
852
|
+
// before we attempted the new subscribe(), so we cannot restore it —
|
|
853
|
+
// any partial subscriptions registered by the failed subscribe() are
|
|
854
|
+
// already in state.unsubscribes and stop() can clean them up.
|
|
855
|
+
// The retry path (scheduleSubscriptionRetry) will attempt a fresh
|
|
856
|
+
// subscribe() against state.unsubscribes; if any partial listeners
|
|
857
|
+
// exist they get added to alongside, but that's no worse than the
|
|
858
|
+
// pre-fix behaviour and avoids the more serious 2× delivery race.
|
|
726
859
|
// Leave state.metaConfig / metaCache / metaTimer untouched so the
|
|
727
|
-
// previous subscription's metadata
|
|
728
|
-
|
|
860
|
+
// previous subscription's metadata behaviour rules are preserved
|
|
861
|
+
// pending retry.
|
|
729
862
|
void previousMetaConfig; // explicit: intentionally unchanged
|
|
863
|
+
void previousUnsubscribes; // intentionally not restored — see above
|
|
730
864
|
// Stash the new meta config on state so the scheduled retry can
|
|
731
865
|
// promote it when subscribe() finally succeeds. Otherwise the
|
|
732
866
|
// operator's new meta settings would silently sit unused until the
|
|
@@ -767,7 +901,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
767
901
|
app
|
|
768
902
|
});
|
|
769
903
|
// ── File-system watchers (delegated to config-watcher module) ────────────
|
|
770
|
-
function setupConfigWatchers() {
|
|
904
|
+
async function setupConfigWatchers() {
|
|
771
905
|
try {
|
|
772
906
|
const watcherConfigs = [
|
|
773
907
|
{ filePath: state.deltaTimerFile, onChange: handleDeltaTimerChange, name: "Delta timer" },
|
|
@@ -789,10 +923,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
789
923
|
// produced by co-located plugins are emitted before our subscription
|
|
790
924
|
// is registered with the subscriptionmanager — those deltas would be
|
|
791
925
|
// silently dropped since the manager only delivers future events.
|
|
792
|
-
handleSubscriptionChange.flush()
|
|
793
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
794
|
-
app.error(`[${instanceId}] Initial subscription load failed: ${msg}`);
|
|
795
|
-
});
|
|
926
|
+
await handleSubscriptionChange.flush();
|
|
796
927
|
app.debug(`[${instanceId}] Configuration file watchers initialized`);
|
|
797
928
|
}
|
|
798
929
|
catch (err) {
|
|
@@ -1067,7 +1198,6 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1067
1198
|
}
|
|
1068
1199
|
state.socketUdp.on("error", handleClientSocketError);
|
|
1069
1200
|
scheduleDeltaTimer();
|
|
1070
|
-
setupConfigWatchers();
|
|
1071
1201
|
// Ping / connectivity monitor (v1 only, RTT measurement)
|
|
1072
1202
|
if ((options.protocolVersion ?? 0) < 2) {
|
|
1073
1203
|
state.pingMonitor = new ping_monitor_1.default({
|
|
@@ -1133,11 +1263,6 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1133
1263
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1134
1264
|
app.debug(`[${instanceId}] initial source snapshot failed: ${msg}`);
|
|
1135
1265
|
});
|
|
1136
|
-
// The initial-subscribe replayValuesSnapshot fires before readyToSend
|
|
1137
|
-
// is true (pipeline not yet created) and silently returns early. Replay
|
|
1138
|
-
// now so data already in the SK tree — including values injected by a
|
|
1139
|
-
// co-located server-mode instance — is forwarded on first connect.
|
|
1140
|
-
replayValuesSnapshot("initial connect");
|
|
1141
1266
|
state.socketUdp.on("message", (msg, rinfo) => {
|
|
1142
1267
|
v2Pipeline.handleControlPacket(msg, rinfo).catch((err) => {
|
|
1143
1268
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -1181,7 +1306,66 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1181
1306
|
}
|
|
1182
1307
|
app.debug(`[${instanceId}] [v1] Client pipeline initialized`);
|
|
1183
1308
|
}
|
|
1309
|
+
// Wire the Signal K subscription after the client send pipeline is ready.
|
|
1310
|
+
// The subscription handler performs one explicit values-snapshot replay;
|
|
1311
|
+
// doing it here prevents the v2 startup path from also sending a second
|
|
1312
|
+
// "initial connect" snapshot.
|
|
1313
|
+
await setupConfigWatchers();
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
function summarizeDeltaForLog(delta) {
|
|
1317
|
+
const fields = getDeltaSummaryFields(delta);
|
|
1318
|
+
return `context=${fields.context}, path=${fields.path}, source=${fields.source}, timestamp=${fields.timestamp}, updates=${fields.updateCount}, values=${fields.valueCount}, suppressed=${metrics.suppressedOutboundDuplicates || 0}`;
|
|
1319
|
+
}
|
|
1320
|
+
function getDeltaSummaryFields(delta) {
|
|
1321
|
+
const update = Array.isArray(delta.updates) ? delta.updates[0] : null;
|
|
1322
|
+
const value = Array.isArray(update?.values) ? update.values[0] : null;
|
|
1323
|
+
const context = delta.context || "?";
|
|
1324
|
+
const path = value?.path || "?";
|
|
1325
|
+
const source = update?.$source || update?.source?.label || "?";
|
|
1326
|
+
const timestamp = update?.timestamp || "?";
|
|
1327
|
+
const updateCount = Array.isArray(delta.updates) ? delta.updates.length : 0;
|
|
1328
|
+
const valueCount = Array.isArray(delta.updates)
|
|
1329
|
+
? delta.updates.reduce((sum, item) => sum + (Array.isArray(item.values) ? item.values.length : 0), 0)
|
|
1330
|
+
: 0;
|
|
1331
|
+
return { context, path, source, timestamp, updateCount, valueCount };
|
|
1332
|
+
}
|
|
1333
|
+
function recordSuppressedDuplicateStats(delta, now) {
|
|
1334
|
+
if (!metrics.suppressedOutboundDuplicateStats) {
|
|
1335
|
+
metrics.suppressedOutboundDuplicateStats = new Map();
|
|
1336
|
+
}
|
|
1337
|
+
const fields = getDeltaSummaryFields(delta);
|
|
1338
|
+
const key = JSON.stringify({
|
|
1339
|
+
context: fields.context,
|
|
1340
|
+
path: fields.path,
|
|
1341
|
+
source: fields.source
|
|
1342
|
+
});
|
|
1343
|
+
const existing = metrics.suppressedOutboundDuplicateStats.get(key);
|
|
1344
|
+
if (existing) {
|
|
1345
|
+
existing.count++;
|
|
1346
|
+
existing.lastUpdate = now;
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
if (metrics.suppressedOutboundDuplicateStats.size >= SUPPRESSED_DUPLICATE_STATS_MAX_SIZE) {
|
|
1350
|
+
let stalestKey = null;
|
|
1351
|
+
let stalestTime = Infinity;
|
|
1352
|
+
for (const [candidateKey, item] of metrics.suppressedOutboundDuplicateStats) {
|
|
1353
|
+
if (item.lastUpdate < stalestTime) {
|
|
1354
|
+
stalestKey = candidateKey;
|
|
1355
|
+
stalestTime = item.lastUpdate;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
if (stalestKey) {
|
|
1359
|
+
metrics.suppressedOutboundDuplicateStats.delete(stalestKey);
|
|
1360
|
+
}
|
|
1184
1361
|
}
|
|
1362
|
+
metrics.suppressedOutboundDuplicateStats.set(key, {
|
|
1363
|
+
context: fields.context,
|
|
1364
|
+
path: fields.path,
|
|
1365
|
+
source: fields.source,
|
|
1366
|
+
count: 1,
|
|
1367
|
+
lastUpdate: now
|
|
1368
|
+
});
|
|
1185
1369
|
}
|
|
1186
1370
|
function stop() {
|
|
1187
1371
|
// If a batch send is in progress, log the warning. We cannot reliably
|
|
@@ -1198,8 +1382,10 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1198
1382
|
state.unsubscribes.forEach((f) => f());
|
|
1199
1383
|
state.unsubscribes = [];
|
|
1200
1384
|
state.localSubscription = null;
|
|
1385
|
+
activeSubscriptionGeneration++;
|
|
1201
1386
|
// Reset runtime state
|
|
1202
1387
|
state.deltas = [];
|
|
1388
|
+
recentOutboundDeltas.clear();
|
|
1203
1389
|
state.timer = false;
|
|
1204
1390
|
state.batchSendInFlight = false;
|
|
1205
1391
|
state.socketRecoveryInProgress = false;
|
package/lib/metrics.js
CHANGED
|
@@ -36,6 +36,8 @@ function createMetrics() {
|
|
|
36
36
|
rateLimitedPackets: 0,
|
|
37
37
|
droppedDeltaBatches: 0,
|
|
38
38
|
droppedDeltaCount: 0,
|
|
39
|
+
suppressedOutboundDuplicates: 0,
|
|
40
|
+
suppressedOutboundDuplicateStats: new Map(),
|
|
39
41
|
remoteNetworkQuality: {
|
|
40
42
|
rtt: 0,
|
|
41
43
|
jitter: 0,
|
|
@@ -156,7 +158,9 @@ function createMetrics() {
|
|
|
156
158
|
dataPacketsReceived: 0,
|
|
157
159
|
rateLimitedPackets: 0,
|
|
158
160
|
droppedDeltaBatches: 0,
|
|
159
|
-
droppedDeltaCount: 0
|
|
161
|
+
droppedDeltaCount: 0,
|
|
162
|
+
suppressedOutboundDuplicates: 0,
|
|
163
|
+
suppressedOutboundDuplicateStats: new Map()
|
|
160
164
|
});
|
|
161
165
|
Object.assign(metrics.bandwidth, {
|
|
162
166
|
bytesOut: 0,
|
package/lib/prometheus.js
CHANGED
|
@@ -47,6 +47,19 @@ function formatPrometheusMetrics(metrics, state, extra = {}, opts = {}) {
|
|
|
47
47
|
counter("rate_limited_packets_total", "Total packets dropped by rate limiting", metrics.rateLimitedPackets || 0);
|
|
48
48
|
counter("dropped_delta_batches_total", "Total delta batches dropped before send", metrics.droppedDeltaBatches || 0);
|
|
49
49
|
counter("dropped_deltas_total", "Total deltas dropped before send", metrics.droppedDeltaCount || 0);
|
|
50
|
+
counter("suppressed_outbound_duplicates_total", "Total exact duplicate outbound deltas suppressed before send", metrics.suppressedOutboundDuplicates || 0);
|
|
51
|
+
if (metrics.suppressedOutboundDuplicateStats) {
|
|
52
|
+
const duplicateStats = Array.from(metrics.suppressedOutboundDuplicateStats.values())
|
|
53
|
+
.sort((a, b) => b.count - a.count)
|
|
54
|
+
.slice(0, 20);
|
|
55
|
+
for (const item of duplicateStats) {
|
|
56
|
+
counter("suppressed_outbound_duplicates_by_path_total", "Total exact duplicate outbound deltas suppressed before send, grouped by context/path/source", item.count, {
|
|
57
|
+
context: item.context,
|
|
58
|
+
path: item.path,
|
|
59
|
+
source: item.source
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
50
63
|
// Error counters
|
|
51
64
|
counter("udp_send_errors_total", "Total UDP send errors", metrics.udpSendErrors);
|
|
52
65
|
counter("udp_retries_total", "Total UDP send retries", metrics.udpRetries);
|
package/lib/routes.js
CHANGED
|
@@ -462,6 +462,7 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
462
462
|
rateLimitedPackets: metrics.rateLimitedPackets || 0,
|
|
463
463
|
droppedDeltaBatches: metrics.droppedDeltaBatches || 0,
|
|
464
464
|
droppedDeltaCount: metrics.droppedDeltaCount || 0,
|
|
465
|
+
suppressedOutboundDuplicates: metrics.suppressedOutboundDuplicates || 0,
|
|
465
466
|
errorCounts: { ...(metrics.errorCounts || {}) }
|
|
466
467
|
},
|
|
467
468
|
status: {
|
package/lib/source-dispatch.js
CHANGED
|
@@ -5,94 +5,159 @@ exports.handleMessageBySource = handleMessageBySource;
|
|
|
5
5
|
function isRecord(value) {
|
|
6
6
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
7
7
|
}
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
const label = source && typeof source.label === "string" ? source.label.trim() : "";
|
|
11
|
-
return label.length > 0 ? label : "";
|
|
8
|
+
function trimmedString(value) {
|
|
9
|
+
return typeof value === "string" ? value.trim() : "";
|
|
12
10
|
}
|
|
13
|
-
function
|
|
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
|
-
}
|
|
11
|
+
function isStaleEdgeLinkRef(sourceRef) {
|
|
19
12
|
return (sourceRef === "signalk-edge-link" ||
|
|
20
13
|
sourceRef.startsWith("signalk-edge-link.") ||
|
|
21
14
|
sourceRef.startsWith("signalk-edge-link:"));
|
|
22
15
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Mirror of signalk-schema's `getSourceId` for use when the incoming update
|
|
18
|
+
* lacks a `$source` string and we have to synthesize one from the structured
|
|
19
|
+
* `source` object. Kept locally so we don't introduce a runtime dependency
|
|
20
|
+
* on signalk-schema just for this one function.
|
|
21
|
+
*
|
|
22
|
+
* Notably: the schema's fallback for a labelled-but-otherwise-empty source
|
|
23
|
+
* is `${label}.XX` (literal "XX"). We deliberately do NOT reproduce that
|
|
24
|
+
* fallback here — emitting a bare label keeps the receiver's $source key
|
|
25
|
+
* identical to what a single-source publisher would have stored.
|
|
26
|
+
*/
|
|
27
|
+
function deriveSourceRefFromObject(source) {
|
|
28
|
+
const label = trimmedString(source.label);
|
|
29
|
+
if (!label) {
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
const canName = trimmedString(source.canName);
|
|
33
|
+
if (canName) {
|
|
34
|
+
return `${label}.${canName}`;
|
|
35
|
+
}
|
|
36
|
+
const src = source.src === undefined || source.src === null ? "" : String(source.src).trim();
|
|
37
|
+
if (src) {
|
|
38
|
+
return `${label}.${src}`;
|
|
39
|
+
}
|
|
40
|
+
const talker = trimmedString(source.talker);
|
|
41
|
+
if (talker) {
|
|
42
|
+
return `${label}.${talker}`;
|
|
43
|
+
}
|
|
44
|
+
return label;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the canonical `$source` string for an update, preferring the
|
|
48
|
+
* incoming `$source` field over a derived value from the structured source
|
|
49
|
+
* object. A stale `signalk-edge-link.*` explicit ref is only replaced when
|
|
50
|
+
* the derived ref is genuinely fresh — otherwise we'd be swapping one stale
|
|
51
|
+
* attribution for another (e.g. when `source.label` is itself
|
|
52
|
+
* `"signalk-edge-link"` on a relayed update), which would collapse keys and
|
|
53
|
+
* misroute downstream subscribers.
|
|
54
|
+
*/
|
|
55
|
+
function resolveSourceRef(update) {
|
|
56
|
+
const explicit = trimmedString(update.$source);
|
|
57
|
+
const sourceObj = isRecord(update.source) ? update.source : null;
|
|
58
|
+
const derived = sourceObj ? deriveSourceRefFromObject(sourceObj) : "";
|
|
59
|
+
if (explicit) {
|
|
60
|
+
if (!isStaleEdgeLinkRef(explicit)) {
|
|
61
|
+
return explicit;
|
|
62
|
+
}
|
|
63
|
+
// Explicit is stale; only swap to derived if derived is genuinely fresh.
|
|
64
|
+
if (!derived || isStaleEdgeLinkRef(derived)) {
|
|
65
|
+
return explicit;
|
|
66
|
+
}
|
|
26
67
|
}
|
|
27
|
-
|
|
28
|
-
delete cloned.$source;
|
|
29
|
-
return cloned;
|
|
68
|
+
return derived;
|
|
30
69
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Build the update object actually handed to `app.handleMessage`.
|
|
72
|
+
*
|
|
73
|
+
* Critically, the structured `source` object is dropped — signalk-server's
|
|
74
|
+
* `FullSignalK.addValue` unconditionally recomputes the leaf's `$source` via
|
|
75
|
+
* `getSourceId(source)` whenever a structured source is present, which in turn
|
|
76
|
+
* uses the providerId-rewritten `source.label` and the hardcoded `.XX` fallback
|
|
77
|
+
* from signalk-schema. Passing the canonical `$source` as a string instead
|
|
78
|
+
* (via the `update.source || update.$source` short-circuit in addUpdate)
|
|
79
|
+
* makes addValue store the leaf under our chosen key verbatim, so a leaf
|
|
80
|
+
* published on the boat as `$source = "bedroom"` lands on every downstream
|
|
81
|
+
* node as `$source = "bedroom"` too.
|
|
82
|
+
*
|
|
83
|
+
* Side effect: per-leaf `pgn` / `sentence` metadata that signalk-schema's
|
|
84
|
+
* `setMessage` would attach from the source object is no longer applied at
|
|
85
|
+
* the receiver — that metadata still rides across the link via the source
|
|
86
|
+
* snapshot envelope (`sendSourceSnapshot` / `mergeSourceSnapshot`) and is
|
|
87
|
+
* available under `/signalk/v1/api/sources`.
|
|
88
|
+
*/
|
|
89
|
+
function prepareUpdateForDispatch(update) {
|
|
90
|
+
const sourceRef = resolveSourceRef(update);
|
|
91
|
+
const prepared = {
|
|
92
|
+
values: Array.isArray(update.values)
|
|
93
|
+
? update.values.map((value) => ({ ...value }))
|
|
94
|
+
: update.values
|
|
44
95
|
};
|
|
45
|
-
|
|
96
|
+
if (update.timestamp !== undefined) {
|
|
97
|
+
prepared.timestamp = update.timestamp;
|
|
98
|
+
}
|
|
99
|
+
if (Array.isArray(update.meta)) {
|
|
100
|
+
prepared.meta = update.meta.map((entry) => ({ ...entry }));
|
|
101
|
+
}
|
|
102
|
+
if (sourceRef) {
|
|
103
|
+
prepared.$source = sourceRef;
|
|
104
|
+
}
|
|
105
|
+
return prepared;
|
|
46
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Strip stale `signalk-edge-link.*` `$source` values from a delta in place
|
|
109
|
+
* (well, by returning a cloned delta when needed). Kept for callers that need
|
|
110
|
+
* to massage a delta without going through full dispatch — currently used by
|
|
111
|
+
* the v2 server pipeline before `_ingestRemoteTelemetry`.
|
|
112
|
+
*
|
|
113
|
+
* After the resolve-source-ref rewrite this is largely a no-op for the
|
|
114
|
+
* dispatch path itself: `resolveSourceRef` already prefers a fresh structured
|
|
115
|
+
* source over a stale `signalk-edge-link.*` `$source`. It remains useful so
|
|
116
|
+
* downstream consumers (e.g. source-replication metrics) see the same
|
|
117
|
+
* normalised `$source` the receiver would store.
|
|
118
|
+
*/
|
|
47
119
|
function normalizeDeltaSourceRefs(delta) {
|
|
48
120
|
if (!delta || !Array.isArray(delta.updates)) {
|
|
49
121
|
return delta;
|
|
50
122
|
}
|
|
51
123
|
let changed = false;
|
|
52
124
|
const updates = delta.updates.map((update) => {
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
55
|
-
|
|
125
|
+
const sourceRef = trimmedString(update.$source);
|
|
126
|
+
if (!sourceRef || !isStaleEdgeLinkRef(sourceRef)) {
|
|
127
|
+
return update;
|
|
128
|
+
}
|
|
129
|
+
const sourceObj = isRecord(update.source) ? update.source : null;
|
|
130
|
+
const sourceLabel = trimmedString(sourceObj?.label);
|
|
131
|
+
// Only strip when we have a real (non-edge-link) structured source the
|
|
132
|
+
// receiver can fall back to. Otherwise keep the stale $source so the
|
|
133
|
+
// value still has *some* attribution downstream. Use prefix-aware
|
|
134
|
+
// staleness detection so a label like `"signalk-edge-link:<instanceId>"`
|
|
135
|
+
// is treated the same as the bare `"signalk-edge-link"`.
|
|
136
|
+
if (!sourceLabel || isStaleEdgeLinkRef(sourceLabel)) {
|
|
137
|
+
return update;
|
|
56
138
|
}
|
|
57
|
-
|
|
139
|
+
changed = true;
|
|
140
|
+
const cloned = { ...update };
|
|
141
|
+
delete cloned.$source;
|
|
142
|
+
return cloned;
|
|
58
143
|
});
|
|
59
144
|
return changed ? { ...delta, updates } : delta;
|
|
60
145
|
}
|
|
61
146
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
147
|
+
* Hand a delta to `app.handleMessage` with `$source` preserved end-to-end.
|
|
148
|
+
*
|
|
149
|
+
* signalk-server's plugin handleMessage wrapper substitutes the calling
|
|
150
|
+
* plugin's id for whatever providerId argument we pass, so dispatching one
|
|
151
|
+
* call per source label is pointless — we always end up with providerId =
|
|
152
|
+
* `"signalk-edge-link"` anyway. The important work is in
|
|
153
|
+
* `prepareUpdateForDispatch`, which drops the structured `source` object so
|
|
154
|
+
* `FullSignalK.addValue` doesn't recompute the leaf's `$source` from the
|
|
155
|
+
* rewritten label.
|
|
67
156
|
*/
|
|
68
157
|
function handleMessageBySource(app, delta) {
|
|
69
158
|
if (!delta || !Array.isArray(delta.updates) || delta.updates.length === 0) {
|
|
70
159
|
return;
|
|
71
160
|
}
|
|
72
|
-
const
|
|
73
|
-
|
|
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
|
-
}
|
|
161
|
+
const prepared = delta.updates.map(prepareUpdateForDispatch);
|
|
162
|
+
app.handleMessage("", { ...delta, updates: prepared });
|
|
98
163
|
}
|
package/lib/values-snapshot.js
CHANGED
|
@@ -33,11 +33,28 @@ function walkValues(node, pathParts, onLeaf) {
|
|
|
33
33
|
if (!isRecord(node)) {
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// `
|
|
39
|
-
//
|
|
40
|
-
//
|
|
36
|
+
// Prefer the top-level single-source view: it reflects the *current*
|
|
37
|
+
// writer, which is what live subscription deltas also carry. The
|
|
38
|
+
// multi-source `values: { … }` map is signalk-server's append-only
|
|
39
|
+
// history bookkeeping — entries created by sources that have since
|
|
40
|
+
// stopped writing (different N2K source address, a previous edge-link
|
|
41
|
+
// version, a one-shot delta from another local provider) stay there
|
|
42
|
+
// for the life of the signalk-server process with no TTL. Walking
|
|
43
|
+
// that map would ship every historical `$source` to the receiver,
|
|
44
|
+
// where they become ghost entries that never refresh because no live
|
|
45
|
+
// writer is producing them anymore.
|
|
46
|
+
const single = readLeafFromNode(node);
|
|
47
|
+
if (single !== null) {
|
|
48
|
+
onLeaf({
|
|
49
|
+
path: pathParts.join("."),
|
|
50
|
+
value: single.value,
|
|
51
|
+
timestamp: single.timestamp,
|
|
52
|
+
source: single.source
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// No top-level leaf — fall back to the multi-source map for older
|
|
57
|
+
// signalk-server versions or partial trees that only populated `values`.
|
|
41
58
|
if (isRecord(node.values)) {
|
|
42
59
|
for (const [sourceLabel, sourceData] of Object.entries(node.values)) {
|
|
43
60
|
if (isRecord(sourceData) &&
|
|
@@ -53,17 +70,6 @@ function walkValues(node, pathParts, onLeaf) {
|
|
|
53
70
|
}
|
|
54
71
|
return;
|
|
55
72
|
}
|
|
56
|
-
// Single-source leaf.
|
|
57
|
-
const single = readLeafFromNode(node);
|
|
58
|
-
if (single !== null) {
|
|
59
|
-
onLeaf({
|
|
60
|
-
path: pathParts.join("."),
|
|
61
|
-
value: single.value,
|
|
62
|
-
timestamp: single.timestamp,
|
|
63
|
-
source: single.source
|
|
64
|
-
});
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
73
|
// Container — descend.
|
|
68
74
|
for (const key of Object.keys(node)) {
|
|
69
75
|
if (SK_LEAF_KEYS.has(key)) {
|
package/package.json
CHANGED
|
@@ -1,165 +1,165 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "signalk-edge-link",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "SignalK Edge Link. Secure UDP link for data exchange.",
|
|
5
|
-
"main": "lib/index.js",
|
|
6
|
-
"files": [
|
|
7
|
-
"lib/",
|
|
8
|
-
"public/"
|
|
9
|
-
],
|
|
10
|
-
"keywords": [
|
|
11
|
-
"signalk-node-server-plugin",
|
|
12
|
-
"signalk-category-network",
|
|
13
|
-
"signalk-webapp",
|
|
14
|
-
"signalk-category-utility",
|
|
15
|
-
"signalk-plugin-configurator"
|
|
16
|
-
],
|
|
17
|
-
"signalk": {
|
|
18
|
-
"appIcon": "./icons/icon-72x72.png",
|
|
19
|
-
"displayName": "Edge Link Configuration"
|
|
20
|
-
},
|
|
21
|
-
"signalk-plugin-enabled-by-default": false,
|
|
22
|
-
"scripts": {
|
|
23
|
-
"clean:lib": "node -e \"const fs=require('fs');if(fs.existsSync('lib'))fs.rmSync('lib',{recursive:true,force:true});\"",
|
|
24
|
-
"clean:public": "node -e \"const fs=require('fs');if(fs.existsSync('public'))fs.rmSync('public',{recursive:true,force:true});\"",
|
|
25
|
-
"build": "npm run build:ts && npm run build:web",
|
|
26
|
-
"build:web": "npm run clean:public && webpack --mode production",
|
|
27
|
-
"build:ts": "npm run clean:lib && tsc",
|
|
28
|
-
"check:ts": "tsc --noEmit",
|
|
29
|
-
"check:release-docs": "node scripts/check-release-truth.js",
|
|
30
|
-
"dev": "webpack --mode development --watch",
|
|
31
|
-
"test": "jest --runInBand",
|
|
32
|
-
"test:v2": "jest __tests__/v2/",
|
|
33
|
-
"test:integration": "jest test/integration/",
|
|
34
|
-
"test:watch": "jest --watch",
|
|
35
|
-
"test:coverage": "jest --coverage",
|
|
36
|
-
"lint": "eslint .",
|
|
37
|
-
"lint:fix": "eslint . --fix",
|
|
38
|
-
"format": "prettier --write \"**/*.{js,ts,json,md}\"",
|
|
39
|
-
"migrate:config": "node lib/scripts/migrate-config.js",
|
|
40
|
-
"cli": "node lib/bin/edge-link-cli.js",
|
|
41
|
-
"prepare": "husky"
|
|
42
|
-
},
|
|
43
|
-
"dependencies": {
|
|
44
|
-
"@msgpack/msgpack": "^3.0.0",
|
|
45
|
-
"@rjsf/core": "^5.18.4",
|
|
46
|
-
"@rjsf/utils": "^5.18.4",
|
|
47
|
-
"@rjsf/validator-ajv8": "^5.18.4",
|
|
48
|
-
"ping-monitor": "^0.8.2"
|
|
49
|
-
},
|
|
50
|
-
"devDependencies": {
|
|
51
|
-
"@babel/core": "^7.22.0",
|
|
52
|
-
"@babel/preset-env": "^7.22.0",
|
|
53
|
-
"@babel/preset-react": "^7.22.0",
|
|
54
|
-
"@testing-library/jest-dom": "^5.17.0",
|
|
55
|
-
"@testing-library/react": "^12.1.5",
|
|
56
|
-
"@types/node": "^25.3.5",
|
|
57
|
-
"@types/react": "^16.14.0",
|
|
58
|
-
"@types/react-dom": "^16.9.0",
|
|
59
|
-
"babel-loader": "^9.1.2",
|
|
60
|
-
"copy-webpack-plugin": "^14.0.0",
|
|
61
|
-
"css-loader": "^6.8.1",
|
|
62
|
-
"eslint": "^8.57.1",
|
|
63
|
-
"eslint-plugin-react": "^7.37.5",
|
|
64
|
-
"html-webpack-plugin": "^5.5.3",
|
|
65
|
-
"husky": "^9.1.7",
|
|
66
|
-
"jest": "^29.7.0",
|
|
67
|
-
"jest-environment-jsdom": "^30.3.0",
|
|
68
|
-
"lint-staged": "^15.4.3",
|
|
69
|
-
"mini-css-extract-plugin": "^2.7.6",
|
|
70
|
-
"prettier": "^3.6.2",
|
|
71
|
-
"react": "^16.13.1",
|
|
72
|
-
"react-dom": "^16.13.1",
|
|
73
|
-
"react-test-renderer": "^16.14.0",
|
|
74
|
-
"style-loader": "^3.3.3",
|
|
75
|
-
"ts-jest": "^29.4.6",
|
|
76
|
-
"ts-loader": "^9.5.4",
|
|
77
|
-
"typescript": "^5.9.3",
|
|
78
|
-
"webpack": "^5.102.1",
|
|
79
|
-
"webpack-cli": "^5.1.4"
|
|
80
|
-
},
|
|
81
|
-
"engines": {
|
|
82
|
-
"node": ">=16"
|
|
83
|
-
},
|
|
84
|
-
"author": "Karl-Erik Gustafsson",
|
|
85
|
-
"repository": "https://github.com/KEGustafsson/signalk-edge-link",
|
|
86
|
-
"homepage": "https://github.com/KEGustafsson/signalk-edge-link#readme",
|
|
87
|
-
"bugs": {
|
|
88
|
-
"url": "https://github.com/KEGustafsson/signalk-edge-link/issues"
|
|
89
|
-
},
|
|
90
|
-
"license": "MIT",
|
|
91
|
-
"jest": {
|
|
92
|
-
"testEnvironment": "node",
|
|
93
|
-
"coverageDirectory": "coverage",
|
|
94
|
-
"collectCoverageFrom": [
|
|
95
|
-
"lib/**/*.js",
|
|
96
|
-
"!lib/webapp/**",
|
|
97
|
-
"!lib/components/**",
|
|
98
|
-
"!lib/utils/**"
|
|
99
|
-
],
|
|
100
|
-
"testMatch": [
|
|
101
|
-
"**/__tests__/**/*.js",
|
|
102
|
-
"**/*.test.js",
|
|
103
|
-
"**/*.spec.js"
|
|
104
|
-
],
|
|
105
|
-
"transform": {
|
|
106
|
-
"^.+\\.js$": [
|
|
107
|
-
"babel-jest",
|
|
108
|
-
{
|
|
109
|
-
"presets": [
|
|
110
|
-
[
|
|
111
|
-
"@babel/preset-env",
|
|
112
|
-
{
|
|
113
|
-
"targets": {
|
|
114
|
-
"node": "current"
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
]
|
|
118
|
-
]
|
|
119
|
-
}
|
|
120
|
-
],
|
|
121
|
-
".+\\.tsx$": [
|
|
122
|
-
"ts-jest",
|
|
123
|
-
{
|
|
124
|
-
"tsconfig": "tsconfig.webapp.json",
|
|
125
|
-
"diagnostics": false
|
|
126
|
-
}
|
|
127
|
-
],
|
|
128
|
-
"^.+\\.ts$": "ts-jest"
|
|
129
|
-
},
|
|
130
|
-
"moduleFileExtensions": [
|
|
131
|
-
"ts",
|
|
132
|
-
"tsx",
|
|
133
|
-
"js",
|
|
134
|
-
"json",
|
|
135
|
-
"node"
|
|
136
|
-
],
|
|
137
|
-
"testPathIgnorePatterns": [
|
|
138
|
-
"/node_modules/",
|
|
139
|
-
"/public/"
|
|
140
|
-
],
|
|
141
|
-
"coverageThreshold": {
|
|
142
|
-
"global": {
|
|
143
|
-
"branches": 60,
|
|
144
|
-
"functions": 65,
|
|
145
|
-
"lines": 65,
|
|
146
|
-
"statements": 65
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
},
|
|
150
|
-
"bin": {
|
|
151
|
-
"edge-link-cli": "lib/bin/edge-link-cli.js"
|
|
152
|
-
},
|
|
153
|
-
"lint-staged": {
|
|
154
|
-
"*.js": [
|
|
155
|
-
"prettier --write",
|
|
156
|
-
"eslint --fix"
|
|
157
|
-
],
|
|
158
|
-
"*.ts": [
|
|
159
|
-
"prettier --write"
|
|
160
|
-
],
|
|
161
|
-
"*.{json,md}": [
|
|
162
|
-
"prettier --write"
|
|
163
|
-
]
|
|
164
|
-
}
|
|
165
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "signalk-edge-link",
|
|
3
|
+
"version": "2.7.0",
|
|
4
|
+
"description": "SignalK Edge Link. Secure UDP link for data exchange.",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"lib/",
|
|
8
|
+
"public/"
|
|
9
|
+
],
|
|
10
|
+
"keywords": [
|
|
11
|
+
"signalk-node-server-plugin",
|
|
12
|
+
"signalk-category-network",
|
|
13
|
+
"signalk-webapp",
|
|
14
|
+
"signalk-category-utility",
|
|
15
|
+
"signalk-plugin-configurator"
|
|
16
|
+
],
|
|
17
|
+
"signalk": {
|
|
18
|
+
"appIcon": "./icons/icon-72x72.png",
|
|
19
|
+
"displayName": "Edge Link Configuration"
|
|
20
|
+
},
|
|
21
|
+
"signalk-plugin-enabled-by-default": false,
|
|
22
|
+
"scripts": {
|
|
23
|
+
"clean:lib": "node -e \"const fs=require('fs');if(fs.existsSync('lib'))fs.rmSync('lib',{recursive:true,force:true});\"",
|
|
24
|
+
"clean:public": "node -e \"const fs=require('fs');if(fs.existsSync('public'))fs.rmSync('public',{recursive:true,force:true});\"",
|
|
25
|
+
"build": "npm run build:ts && npm run build:web",
|
|
26
|
+
"build:web": "npm run clean:public && webpack --mode production",
|
|
27
|
+
"build:ts": "npm run clean:lib && tsc",
|
|
28
|
+
"check:ts": "tsc --noEmit",
|
|
29
|
+
"check:release-docs": "node scripts/check-release-truth.js",
|
|
30
|
+
"dev": "webpack --mode development --watch",
|
|
31
|
+
"test": "jest --runInBand",
|
|
32
|
+
"test:v2": "jest __tests__/v2/",
|
|
33
|
+
"test:integration": "jest test/integration/",
|
|
34
|
+
"test:watch": "jest --watch",
|
|
35
|
+
"test:coverage": "jest --coverage",
|
|
36
|
+
"lint": "eslint .",
|
|
37
|
+
"lint:fix": "eslint . --fix",
|
|
38
|
+
"format": "prettier --write \"**/*.{js,ts,json,md}\"",
|
|
39
|
+
"migrate:config": "node lib/scripts/migrate-config.js",
|
|
40
|
+
"cli": "node lib/bin/edge-link-cli.js",
|
|
41
|
+
"prepare": "husky"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@msgpack/msgpack": "^3.0.0",
|
|
45
|
+
"@rjsf/core": "^5.18.4",
|
|
46
|
+
"@rjsf/utils": "^5.18.4",
|
|
47
|
+
"@rjsf/validator-ajv8": "^5.18.4",
|
|
48
|
+
"ping-monitor": "^0.8.2"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@babel/core": "^7.22.0",
|
|
52
|
+
"@babel/preset-env": "^7.22.0",
|
|
53
|
+
"@babel/preset-react": "^7.22.0",
|
|
54
|
+
"@testing-library/jest-dom": "^5.17.0",
|
|
55
|
+
"@testing-library/react": "^12.1.5",
|
|
56
|
+
"@types/node": "^25.3.5",
|
|
57
|
+
"@types/react": "^16.14.0",
|
|
58
|
+
"@types/react-dom": "^16.9.0",
|
|
59
|
+
"babel-loader": "^9.1.2",
|
|
60
|
+
"copy-webpack-plugin": "^14.0.0",
|
|
61
|
+
"css-loader": "^6.8.1",
|
|
62
|
+
"eslint": "^8.57.1",
|
|
63
|
+
"eslint-plugin-react": "^7.37.5",
|
|
64
|
+
"html-webpack-plugin": "^5.5.3",
|
|
65
|
+
"husky": "^9.1.7",
|
|
66
|
+
"jest": "^29.7.0",
|
|
67
|
+
"jest-environment-jsdom": "^30.3.0",
|
|
68
|
+
"lint-staged": "^15.4.3",
|
|
69
|
+
"mini-css-extract-plugin": "^2.7.6",
|
|
70
|
+
"prettier": "^3.6.2",
|
|
71
|
+
"react": "^16.13.1",
|
|
72
|
+
"react-dom": "^16.13.1",
|
|
73
|
+
"react-test-renderer": "^16.14.0",
|
|
74
|
+
"style-loader": "^3.3.3",
|
|
75
|
+
"ts-jest": "^29.4.6",
|
|
76
|
+
"ts-loader": "^9.5.4",
|
|
77
|
+
"typescript": "^5.9.3",
|
|
78
|
+
"webpack": "^5.102.1",
|
|
79
|
+
"webpack-cli": "^5.1.4"
|
|
80
|
+
},
|
|
81
|
+
"engines": {
|
|
82
|
+
"node": ">=16"
|
|
83
|
+
},
|
|
84
|
+
"author": "Karl-Erik Gustafsson",
|
|
85
|
+
"repository": "https://github.com/KEGustafsson/signalk-edge-link",
|
|
86
|
+
"homepage": "https://github.com/KEGustafsson/signalk-edge-link#readme",
|
|
87
|
+
"bugs": {
|
|
88
|
+
"url": "https://github.com/KEGustafsson/signalk-edge-link/issues"
|
|
89
|
+
},
|
|
90
|
+
"license": "MIT",
|
|
91
|
+
"jest": {
|
|
92
|
+
"testEnvironment": "node",
|
|
93
|
+
"coverageDirectory": "coverage",
|
|
94
|
+
"collectCoverageFrom": [
|
|
95
|
+
"lib/**/*.js",
|
|
96
|
+
"!lib/webapp/**",
|
|
97
|
+
"!lib/components/**",
|
|
98
|
+
"!lib/utils/**"
|
|
99
|
+
],
|
|
100
|
+
"testMatch": [
|
|
101
|
+
"**/__tests__/**/*.js",
|
|
102
|
+
"**/*.test.js",
|
|
103
|
+
"**/*.spec.js"
|
|
104
|
+
],
|
|
105
|
+
"transform": {
|
|
106
|
+
"^.+\\.js$": [
|
|
107
|
+
"babel-jest",
|
|
108
|
+
{
|
|
109
|
+
"presets": [
|
|
110
|
+
[
|
|
111
|
+
"@babel/preset-env",
|
|
112
|
+
{
|
|
113
|
+
"targets": {
|
|
114
|
+
"node": "current"
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
],
|
|
121
|
+
".+\\.tsx$": [
|
|
122
|
+
"ts-jest",
|
|
123
|
+
{
|
|
124
|
+
"tsconfig": "tsconfig.webapp.json",
|
|
125
|
+
"diagnostics": false
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
"^.+\\.ts$": "ts-jest"
|
|
129
|
+
},
|
|
130
|
+
"moduleFileExtensions": [
|
|
131
|
+
"ts",
|
|
132
|
+
"tsx",
|
|
133
|
+
"js",
|
|
134
|
+
"json",
|
|
135
|
+
"node"
|
|
136
|
+
],
|
|
137
|
+
"testPathIgnorePatterns": [
|
|
138
|
+
"/node_modules/",
|
|
139
|
+
"/public/"
|
|
140
|
+
],
|
|
141
|
+
"coverageThreshold": {
|
|
142
|
+
"global": {
|
|
143
|
+
"branches": 60,
|
|
144
|
+
"functions": 65,
|
|
145
|
+
"lines": 65,
|
|
146
|
+
"statements": 65
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
"bin": {
|
|
151
|
+
"edge-link-cli": "lib/bin/edge-link-cli.js"
|
|
152
|
+
},
|
|
153
|
+
"lint-staged": {
|
|
154
|
+
"*.js": [
|
|
155
|
+
"prettier --write",
|
|
156
|
+
"eslint --fix"
|
|
157
|
+
],
|
|
158
|
+
"*.ts": [
|
|
159
|
+
"prettier --write"
|
|
160
|
+
],
|
|
161
|
+
"*.{json,md}": [
|
|
162
|
+
"prettier --write"
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
}
|